├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── _config.yml ├── boot-app ├── clients-kafkacat └── topic-tail ├── clients-spring ├── pom.xml └── src │ ├── main │ ├── java │ │ ├── module-info.java │ │ └── no │ │ │ └── nav │ │ │ └── kafka │ │ │ └── sandbox │ │ │ ├── Application.java │ │ │ ├── consolemessages │ │ │ ├── ConsoleMessagesConfig.java │ │ │ ├── ConsoleMessagesConsumer.java │ │ │ └── ConsoleMessagesRestController.java │ │ │ ├── data │ │ │ ├── DefaultEventStore.java │ │ │ ├── EventStore.java │ │ │ └── EventStoreWithFailureRate.java │ │ │ └── measurements │ │ │ ├── MeasurementsConfig.java │ │ │ ├── MeasurementsConsumer.java │ │ │ ├── MeasurementsRestController.java │ │ │ └── errorhandlers │ │ │ ├── RecoveringErrorHandler.java │ │ │ └── RetryingErrorHandler.java │ └── resources │ │ ├── application.yml │ │ └── public │ │ ├── index.html │ │ ├── measurements.html │ │ └── messages.html │ └── test │ └── java │ └── no │ └── nav │ └── kafka │ └── sandbox │ └── data │ └── EventStoreWithFailureRateTest.java ├── clients ├── pom.xml └── src │ ├── main │ ├── java │ │ ├── module-info.java │ │ └── no │ │ │ └── nav │ │ │ └── kafka │ │ │ └── sandbox │ │ │ ├── Bootstrap.java │ │ │ ├── KafkaConfig.java │ │ │ ├── admin │ │ │ └── TopicAdmin.java │ │ │ ├── consumer │ │ │ └── JsonMessageConsumer.java │ │ │ └── producer │ │ │ ├── JsonMessageProducer.java │ │ │ ├── NullMessageProducer.java │ │ │ └── StringMessageProducer.java │ └── resources │ │ └── simplelogger.properties │ └── test │ ├── java │ └── no │ │ └── nav │ │ └── kafka │ │ └── sandbox │ │ ├── DockerComposeEnv.java │ │ ├── DockerComposeEnvTest.java │ │ └── KafkaSandboxTest.java │ └── resources │ └── KafkaDockerComposeEnv.yml ├── docker-compose.yml ├── messages ├── pom.xml └── src │ └── main │ └── java │ ├── module-info.java │ └── no │ └── nav │ └── kafka │ └── sandbox │ └── messages │ ├── ConsoleMessages.java │ ├── Measurements.java │ └── SequenceValidation.java ├── pom.xml └── run /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | /.idea/ 3 | *.iml 4 | sequence-producer.state 5 | dependency-reduced-pom.xml 6 | 7 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @navikt/teampam 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 NAV 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kafka sandbox - experiments with Kafka clients and Spring Kafka 2 | 3 | - Kafka Java client 3.6, Java 17+ 4 | - Spring Kafka 3.1, Spring Boot 3.2 (optional in separate module) 5 | - Basic plain Java producer/consumer clients with minimal dependencies. 6 | - A Spring Boot application with Kafka consumer endpoints, internal storage and 7 | web interfaces. 8 | - Batch error handling with Spring Kafka. 9 | - Learn by doing, observing and experimenting. 10 | 11 | ## Purpose 12 | 13 | - Get quickly up and running with Kafka using the standard Java Kafka clients. 14 | - Experiment with the console clients to learn about communication patterns 15 | possible with Kafka, how topic partitions and consumer groups work in 16 | practice, and how error conditions affect the clients and the communication. 17 | - Experiment with the settings to learn and understand behaviour, easily modify 18 | and re-run code in the experimentation process. 19 | - Learn and experiment with setup of multiple Kafka consumers in a Spring Boot 20 | application. 21 | - Learn about batch error handling strategies in Spring Kafka. 22 | - Contains code and examples of tests that use a local temporary Kafka 23 | environment to execute. 24 | - Even though not the primary purpose of this project: Learn about Java module 25 | system and modular Maven builds. 26 | 27 | It is important to note that this is not a complete guide to Kafka or Spring 28 | Kafka in any sense. It can however serve as a base setup (or "sandbox") which 29 | will enable you to quickly experiment with Kafka clients and Spring Kafka. 30 | Things can get really complicated, and sometimes you need to actually test how 31 | things work in a small and controlled environment, to understand the technology 32 | and apply it correctly in real production scenarios. 33 | 34 | ## Requirements 35 | 36 | - [JDK 17+][1] 37 | - [Maven][2] 3.8.X+ (must be able to handle modular Java project and modern 38 | Java compiler options) 39 | - A working [Docker][3] installation on localhost ([Docker for Windows][4] is 40 | fine), and [docker compose][5]. 41 | - A unix-like shell is very handy, but not a strict requirement. 42 | 43 | [1]: https://adoptium.net/temurin/releases/ 44 | [2]: https://maven.apache.org/ 45 | [3]: https://www.docker.com/ 46 | [4]: https://docs.docker.com/docker-for-windows/install/ 47 | [5]: https://docs.docker.com/compose/ 48 | 49 | ## Recommended reading 50 | 51 | https://kafka.apache.org/documentation/#gettingStarted 52 | 53 | This page explains a lot of concepts which are useful to know about beforehand. 54 | 55 | And if interested in Spring Kafka: 56 | https://docs.spring.io/spring-kafka/reference/index.html 57 | 58 | ## Index 59 | 60 | 1. [Getting started](#getting-started) 61 | 2. [Communication patterns with Kafka](#kafka-patterns) 62 | 1. [One to one](#kafka-one-to-one) 63 | 2. [One to many](#kafka-one-to-many) 64 | 3. [One time processing with parallel consumer group](#kafka-one-time-parallel) 65 | 4. [Many to many](#kafka-many-to-many) 66 | 5. [Consumer group rebalancing](#kafka-consumer-group-rebalancing) 67 | 6. [Many to one](#kafka-many-to-one) 68 | 7. [Error handling: broker goes down](#kafka-error-broker-down) 69 | 8. [Error handling: detecting message loss](#kafka-message-loss) 70 | 9. [Error handling: consumer dies](#kafka-consumer-dies) 71 | 3. [The Spring Boot application](#spring-boot) 72 | 1. [Running](#spring-running) 73 | 2. [Web interfaces](#spring-web-interfaces) 74 | 3. [Talk to Spring boot application (or yourself) using Kafka](#spring-talk-to) 75 | 4. [Experiment with Spring application](#spring-experiment) 76 | 5. [Batch consumer error handling in Spring Kafka](#spring-batch-error-1) 77 | 6. [Batch consumer error handling in Spring Kafka: infinite retries](#spring-batch-error-2) 78 | 7. [Batch consumer error handling in Spring Kafka: limited retries](#spring-batch-error-4) 79 | 8. [Batch consumer error handling in Spring Kafka: really limited retries](#spring-batch-error-4) 80 | 9. [Batch consumer error handling in Spring Kafka: recovery](#spring-batch-error-5) 81 | 10. [What about transient failures when storing events ?](#spring-batch-error-6) 82 | 11. [Handling failure to deserialize messages in batch consumer](#spring-batch-error-7) 83 | 12. [Stopping consumer on fatal errors](#spring-batch-error-8) 84 | 85 | 4. [Tuning logging to get more details](#log-tuning) 86 | 5. [Unit/integration tests with `DockerComposeEnv`](#integration-tests) 87 | 6. [Using kafkacat to inspect Kafka topics](#kafkacat) 88 | 7. [Using official Kafka command line tools](#kafka-cmds) 89 | 90 | ## Getting started 91 | 92 | ### Building 93 | 94 | The project consists of three Maven modules: 95 | 96 | 1. messages 97 | 2. clients 98 | 3. clients-spring 99 | 100 | The `messages` module is used by both the Spring application and regular command 101 | line clients and contains various messages types and handling of them. 102 | 103 | The build process is boring and very standard, but does test that Docker and 104 | docker-compose works on your host: 105 | 106 | mvn install 107 | 108 | If all goes well, an executable jar is built in 109 | `clients/target/clients-.jar` for the basic Java clients. The automated 110 | tests actually spin up Kafka on localhost, and so take a while to complete. To 111 | skip the tests during development iterations, use `mvn install -DskipTests` 112 | instead. 113 | 114 | The jar-file can be executed simply by running `./run` from the project 115 | top directory, or alternatively using `java -jar clients/target/clients-*.jar`. 116 | 117 | ### Running a Kafka environment on localhost 118 | 119 | Ensure the can you can get Kafka up and running on localhost. For running the 120 | command line clients or Spring boot application of kafka-sandbox, all you need 121 | to do is run the following in a dedicated terminal with current directory being 122 | the kafka-sandbox project directory: 123 | 124 | docker compose up -d && docker compose logs -f 125 | 126 | To shut down the Kafka service containers, press `CTRL+C` to exit the logs view 127 | and then type: 128 | 129 | docker compose down 130 | 131 | ### Running the kafka-sandbox command line clients 132 | 133 | To get started: 134 | 135 | $ chmod +x run 136 | $ ./run --help 137 | Use: 'producer [TOPIC [P]]' or 'consumer [TOPIC [GROUP]]' 138 | Use: 'produce N [TOPIC [P]]' to produce exactly N measurements to topic with no delay 139 | Use: 'console-message-producer [TOPIC [P]]' or 'console-message-consumer [TOPIC [GROUP]]' 140 | Use: 'sequence-producer [TOPIC [P]]' or 'sequence-consumer [TOPIC [GROUP]]' 141 | Use: 'null-producer [TOPIC [P]]' to produce a single message with null value 142 | Use: 'string-producer STRING [TOPIC [P]]' to produce a single UTF-8 encoded string message 143 | Use: 'newtopic TOPIC [N]' to create a topic with N partitions (default 1). 144 | Use: 'deltopic TOPIC' to delete a topic. 145 | Use: 'showtopics' to list topics/partitions available. 146 | Default topic is chosen according to consumer/producer type. 147 | Default consumer group is 'console' 148 | Kafka broker is localhost:9092 149 | 150 | *Note: after code changes, you should rebuild the project with `mvn install` so 151 | that executable jars are updated. The convenience run scripts will not 152 | automatically perform rebuilds.* 153 | 154 | The producer and consumer modes are paired according to the type of messages 155 | they can exchange. The default 'producer' creates synthentic "temperature 156 | measurement" events automatically after starting up, hence the naming of the 157 | corresponding default Kafka topic. The default 'consumer' is able to read these 158 | messages and display them as console output. 159 | 160 | The 'sequence-producer' creates records with an ever increasing sequence number. 161 | The corresponding consumer does a simple validation of received messages, 162 | checking that the received sequence number is the expected one. This can be used 163 | to detect if messages are lost or reordered in various situations. The consumer 164 | keeps an account of the number of errors detected and writes status to stdout 165 | upon message reception. 166 | 167 | The 'console-message-producer' is an interactive producer that reads messages 168 | you type on the command line and ships them off to a Kafka topic. The 169 | 'console-message-consumer' is able to read these messages and display them as 170 | console output. These can be used to get a more controlled message production 171 | where sending is driven by user input. 172 | 173 | The 'null-producer' produces a single record with a null value to a topic. Used 174 | for testing error handling of poison pill messages. The 'string-producer' can 175 | produce records with UTF-8 encoded strings as payload. It can be used to trigger 176 | deserialization errors for consumers expecting JSON payload. 177 | 178 | The commands 'newtopic', 'deltopic' and 'showtopics' allow simple administration 179 | of Kafka topics for testing purposes. 180 | 181 | ### Tip regarding terminal usage 182 | 183 | The examples generally require multiple terminal windows, in order to start 184 | different producer/consumer processes in parallel with visible output. Using 185 | only terminal tabs is perhaps not the best option, because you will not be able 186 | to see in real time the different outputs together. If you are already using 187 | something like tmux you can benefit greatly by using multiple panes and windows. 188 | 189 | ### Running directly from IntelliJ 190 | 191 | You can create run configurations in IntelliJ for all the examples, by starting 192 | the `no.nav.kafka.sandbox.Bootstrap` class with the arguments and create a 193 | Spring boot run configuration for the `Application` class in `clients-spring`. 194 | So a shell is not strictly required. 195 | 196 | ## Communication patterns with Kafka 197 | 198 | *These examples assume that you have a local Kafka broker up and running on `localhost:9092`, 199 | see [relevant section](#local-kafka).* 200 | 201 | ### Example: one to one 202 | 203 | This example is possibly the simplest case and can be easily demonstrated using 204 | the command line clients in kafka-sandbox. 205 | 206 | We will use the default topic with a single partition: 207 | 208 | *In terminal 1:* 209 | 210 | ./run producer 211 | 212 | The producer will immediately start sending messages to the Kafka topic 213 | 'measurements'. Since this default topic only has one partition, the exact place 214 | where the messages will be stored can be denoted as 'measurements-0', meaning 215 | partition 0 for the topic. 216 | 217 | *In terminal 2:* 218 | 219 | ./run consumer 220 | 221 | The consumer will connect to Kafka and starting polling for messages. It will 222 | display the messages in the console as they arrive. The consumer subscribes to 223 | the topic 'measurements', but does not specify any partition in particular. So 224 | it will be assigned a partition automatically. 225 | 226 | The consumer uses the default consumer group 'console'. The consumer group 227 | concept is important to understand: 228 | 229 | 1. The consumer group is simply a named identifier chosen by the clients. 230 | 2. There can only be *one* consumer client instance in a particular consumer 231 | group assigned to a single topic-partition at any given time. 232 | 3. Consumed partition offsets for a topic is stored per *consumer group*. In 233 | other words, Kafka stores the progress on a *per consumer group* basis, for a 234 | particular topic and its partitions. (Consumer clients are responsible for 235 | comitting the progress back to Kafka.) 236 | 4. When a new consumer group name is established, the consumers which are part 237 | of that group will typically start receiving only new messages sent to the 238 | topic. This is however configurable, and the consumers in kafka-sandbox are 239 | by default setup to start at the very beginning of a topic, if Kafka has no 240 | stored offset data for the group to begin with. 241 | 5. When the constellation of consumers in the same consumer group connected to a 242 | topic changes, Kafka will rebalance the consumers and possibly reassign 243 | partitions within the group. 244 | 245 | To observe what happens when a consumer disconnects and reconnects to the same topic: 246 | 247 | 1. Stop the running consumer in terminal 2 by hitting `CTRL+C`. (You may notice 248 | in the Kafka broker log that the consumer instance left the topic.) 249 | 2. Start the consumer again. Notice that it does not start at the beginning of 250 | the Kafka topic log, but continues from the offset where it left off. This is 251 | because the consumer group offset is stored server side. 252 | 3. Kill the consumer again, and restart with a different (new) consumer group: 253 | 254 | ./run consumer measurements othergroup 255 | 256 | Notice how it now starts displaying messages from the very beginning of the 257 | topic (offset 0). This is because no previous offset has been stored for the 258 | 'othergroup' group in Kafka and the client is configured to start at the 259 | beginning of the topic in that situation. 260 | 261 | What happens when a second consumer joins ? Start a second consumer in a new 262 | terminal window: 263 | 264 | ./run consumer measurements othergroup 265 | 266 | You will now notice that one of the two running consumers will stop receiving 267 | messages, and in that case the following message will appear: 268 | 269 | > Rebalance: no longer assigned to topic measurements, partition 0 270 | 271 | This is because the topic only has one partition, and only one consumer in a 272 | single consumer group can be associated with a single topic partition at a time. 273 | 274 | If you now kill the consumer that currently has the assignment (and shows 275 | received messages), you will notice that Kafka does a new rebalancing, and the 276 | previously idle consumer gets assigned back to the partition and starts 277 | receiving messages where the other one left off. 278 | 279 | ### Example: one to many 280 | 281 | One to many means that a single message produced on a topic is typically 282 | processed by any number of different consumer groups. 283 | 284 | Initialize a new topic with 1 partition and start a producer: 285 | 286 | ./run newtopic one_to_many 1 287 | 288 | ./run producer one_to_many 289 | 290 | 291 | And fire up as many consumers as desired in new terminal windows, but increment 292 | the group number N for each one: 293 | 294 | ./run consumer one_to_many group-N 295 | 296 | You will notice that all the consumer instances report the same messages and 297 | offsets after a short while. Because they are all in different consumer groups, 298 | they all see the messages that the single producer sends. 299 | 300 | ### Example: one time message processing with parallel consumer group 301 | 302 | In this scenario, it is only desirable to process a message once, but it can be 303 | processed by any consumer in a consumer group. 304 | 305 | Create a topic with 3 partitions: 306 | 307 | ./run newtopic any_once 3 308 | 309 | Start three producers in three terminals, one for each partition: 310 | 311 | ./run producer any_once 0 312 | 313 | ./run producer any_once 1 314 | 315 | ./run producer any_once 2 316 | 317 | Here we are explicitly specifying which partition each producer should write to, 318 | so that we ensure an even distribution of messages for the purpose of this 319 | example. If partition is left unspecified, the producer will select a partition 320 | based on the Kafka record keys. The producer of "measurement" messages in the 321 | demo code uses a fixed "sensor device id" based on the PID as key, and so the 322 | messages become fixed to a random partition. See the Apache code for class 323 | `org.apache.kafka.clients.producer.internals.DefaultPartitioner` - it is not 324 | complicated and explains it in detail. The partitioner class [strategy] to use 325 | is part of the Kafka producer config. 326 | 327 | Next, we are going to start consumer processes. 328 | 329 | Begin with a single consumer: 330 | 331 | ./run consumer any_once group 332 | 333 | You will notice that this first consumer gets assigned all three partitions on 334 | the topic and starts displaying received messages. 335 | 336 | Let's scale up to another consumer. Run in a new terminal: 337 | 338 | ./run consumer any_once group 339 | 340 | When this consumer joins, you can see rebalancing messages, and it will be 341 | assigned one or two partitions from the topic, while the first is removed from 342 | the corresponding number of partitions. Now the load is divided betweeen the two 343 | running consumers. 344 | 345 | Scale further by starting a third consumer in a new terminal: 346 | 347 | ./run consumer any_once group 348 | 349 | After the third one joins, a new rebalancing will occur and they will each have 350 | one partition assigned. Now the load is divided evenly and messages are 351 | processed by three parallel processes. 352 | 353 | Try to start another fourth consumer (same topic/group) and see what happens. 354 | (Hint: you will not gain anything wrt. message processing capacity.) 355 | 356 | 357 | ### Many to many 358 | 359 | The previous example can also be considered a many to many example if more 360 | consumers are started in several active consumer groups. In that case, all the 361 | messages produced will be handled in parallel by several different groups (but 362 | only once per group). 363 | 364 | 365 | ### Consumer group rebalancing 366 | 367 | You will notice log messages from the consumers whenever a consumer group 368 | rebalancing occurs. This typically happens when a consumer leaves or a new 369 | consumer arrives. It will provide insight into how Kafka distributes messages 370 | amongst consumers in a group. 371 | 372 | 373 | ### Many to one 374 | 375 | This example demonstrates a many-to-one case, where there are lots of producers 376 | collecting "temperature sensor events" and sending it to a common topic, while a 377 | single consumer is responsible for processing the messages. 378 | 379 | Start a single consumer for topic 'manydevices': 380 | 381 | ./run consumer manydevices 382 | 383 | Start 10 producers by executing the following command 10 times: 384 | 385 | $ ./run producer manydevices 1>/dev/null 2>&1 & 386 | [x10..] 387 | 388 | The producers will be started in the background by the shell and the output is 389 | hidden. (You can examine running background jobs with the command `jobs`.) 390 | 391 | After a short while, you should see the consumer receiving and processing 392 | messages from many different producers ("sensor devices"). Depending on the 393 | number of running producers, you may see the consumer receiving multiple records 394 | per poll call to Kafka. This is simply due to the increased rate of messages 395 | being written to the topic. 396 | 397 | To kill all producers running in the background, execute command: 398 | 399 | kill $(jobs -p) 400 | 401 | 402 | ### Error handling in general 403 | 404 | The demo clients in this app are "unsafe" with regard to message sending and 405 | reception. The producer does not care about failed sends, but merely logs it as 406 | unfortunate events. Depending on business requirements, you will likely need to 407 | take proper care of exception handling and retry policies, to ensure no loss of 408 | events at either the producing or consuming end. 409 | 410 | ### Error handling: broker goes down 411 | 412 | What happens to a producer/consumer when the broker suddenly stops responding ? 413 | In particular, what happens to the messages that are being sent ? Are they lost 414 | or can they be accidentally reordered ? 415 | 416 | Here is a recipe to experiment with such scenarios. 417 | 418 | Run a producer and a consumer in two windows: 419 | 420 | $ ./run producer 421 | [...] 422 | 423 | $ ./run consumer 424 | [...] 425 | 426 | Then pause the docker container with the broker to simulate that it stops 427 | responding: 428 | 429 | docker compose pause broker 430 | 431 | Now watch the error messages from the producer that will eventually appear. A 432 | prolonged pause will actually cause messages to be lost with the current 433 | kafka-sandbox code. It keeps trying to send new messages without really caring 434 | what happens to already dispatched ones. Depending on use case, this may not be 435 | desirable, and one may need to develop code that always retries failed sends to 436 | avoid losing events. 437 | 438 | Make the broker respond again: 439 | 440 | docker compose unpause broker 441 | 442 | The producer recovers and sends its internal buffer of messages that have not 443 | yet expired due to timeouts. 444 | 445 | You may also restart the broker entirely, which causes it to lose its runtime 446 | state, and see what happens with the clients: 447 | 448 | docker compose restart broker 449 | 450 | or: 451 | 452 | docker-compose stop broker 453 | # wait a while.. 454 | docker-compose start broker 455 | 456 | You'll notice that the clients recover eventually, but if it is down for too 457 | long, messages will be lost. Also, you will notice rebalance notifications from 458 | consumers once they are able to reconnect to the broker. 459 | 460 | Behaviour can be adjusted by the many config options that the Kafka clients 461 | support. You can experiment and modify config by editing the code in 462 | `no.nav.kafka.sandbox.KafkaConfig`, see `#kafkaProducerProps()` and 463 | `#kafkaConsumerProps(String)`. 464 | 465 | Useful URLs for Kafka configuration docs: 466 | 467 | http://kafka.apache.org/documentation.html#consumerconfigs 468 | 469 | http://kafka.apache.org/documentation.html#producerconfigs 470 | 471 | ### Error handling: detecting message loss with sequence-producer/consumer 472 | 473 | The 'sequence-producer' and corresponding 'sequence-consumer' commands can be 474 | used for simple detection of message loss or reordering. The producer will send 475 | messages containing an ever increasing sequence number, and the consumer 476 | validates that the messages it receives have the expected next number in the 477 | sequence. When validation fails it logs errors and increases an error counter, 478 | so that it is easy to spot. 479 | 480 | Start the producer: 481 | 482 | ./run sequence-producer 483 | 484 | It will start at sequence number 0. If you restart, it will continue from where 485 | was last stopped, since the next sequence number is persisted to a temporary 486 | file. (To reset this, stop the sequence-producer and remove the file 487 | `sequence-producer.state`.) 488 | 489 | Now start the corresponding consumer: 490 | 491 | ./run sequence-consumer 492 | 493 | It will read the sequence numbers already on the topic and log its state upon 494 | every message reception. You should see that the sequence is "in sync" and that 495 | the error count is 0. 496 | 497 | While they are running, restart the Kafka broker: 498 | 499 | docker compose restart broker 500 | 501 | You should see the producer keeps sending messages, but does not receive 502 | acknowledgements. Eventually it will log errors about expired messages. The 503 | consumer may also start logging errors about connectivity, depending on how long 504 | the broker is down, which depends on how fast the host machine is. (If the 505 | broker restart is too quick to cause any errors, use "stop/start" instead, and 506 | wait a little while before starting.) 507 | 508 | Normally, with the current code in kafka-sandbox, you can observe that some 509 | messages are lost in this process, and the consumer increases the error count 510 | due to receiving an unexpected sequence number. 511 | 512 | There is a challenge here: modify the kafka-sandbox code or config make it more 513 | resilient. Ensure that no sequence messages are lost if Kafka stops responding 514 | for about 60 seconds, for whatever reason. Test by re-running the procedure 515 | described in this section. (Hint: see the various producer timeout config 516 | parameters.) 517 | 518 | To only display output related to the sequence number producer/consumer, you can 519 | pipe the output of the start commands to `...|grep SEQ`, which will filter out 520 | the other log messages. 521 | 522 | 523 | ### Error handling: consumer dies 524 | 525 | What happens within a consumer group when an active consumer suddenly becomes 526 | unavailable ? 527 | 528 | Start a producer and two consumers with a simple 1 partition topic: 529 | 530 | ./run producer sometopic 531 | 532 | Then two consumers in other terminal windows: 533 | 534 | ./run consumer sometopic group 535 | 536 | You will notice that one of the consumers is idle (no "untaken" partitions in 537 | consumer group), and the other one is assigned the active partition and is 538 | processing messages. Figure out the PID of the *active* consumer and kill it 539 | with `kill -9`. (The PID is printed to the console right after the consumer is 540 | started.) 541 | 542 | kill -9 543 | 544 | This causes a sudden death of the consumer process and it will take a short 545 | while until Kafka notices that the consumer is gone. Watch the broker log and 546 | what eventually happens with the currently idle consumer. (Note that it may take 547 | up to a minute before Kafka decides to hand the partition over to the idle, but 548 | live consumer.) 549 | 550 | ## The Spring Boot application 551 | 552 | The Spring Boot application is implemented in Maven module `clients-spring/`. 553 | 554 | *The application requires that you have a local Kafka broker up and running on 555 | `localhost:9092`, see [relevant section](#local-kafka).* 556 | 557 | ### Running 558 | 559 | From the top level directory: 560 | 561 | mvn install 562 | java -jar clients-spring/target/clients-spring-*.jar 563 | 564 | .. or use the convenience script `boot-app`, which is used in all examples: 565 | 566 | chmod +x boot-app 567 | ./boot-app 568 | 569 | *Note: after code changes, you should rebuild the project with `mvn install` so 570 | that executable jars are updated. The convenience run scripts will not 571 | automatically perform rebuilds.* 572 | 573 | The application will automatically subscribe to and start consuming messages 574 | from the topics `measurements` (the standard producer in previous examples) and 575 | `messages` (for messages created by `console-message-producer` client). The 576 | consumed messages are stored in-memory in a fixed size event store that also 577 | detects duplicates. 578 | 579 | ### Web interfaces 580 | 581 | A welcome page links to the Measurements and Messages pages of the application. 582 | Navigate to http://localhost:8080/ to open it. 583 | 584 | #### Measurements page 585 | 586 | http://localhost:8080/measurements.html 587 | 588 | A web page showing measurements/"sensor event" messages from Kafka. It uses an 589 | API endpoint available at http://localhost:8080/measurements/api 590 | 591 | #### Messages page 592 | 593 | http://localhost:8080/messages.html 594 | 595 | A web page showing "console message" events from Kafka. It uses an API endpoint 596 | available at http://localhost:8080/messages/api 597 | 598 | 599 | ### Talk to Spring boot app (or yourself) using Kafka 600 | 601 | 1. In one terminal, start the Spring boot application as described earlier. 602 | 603 | 2. In another terminal, from project root dir, start the command line console 604 | message producer: 605 | 606 | $ ./run console-message-producer 607 | 36 [main] INFO Bootstrap - New producer with PID 19445 608 | 191 [main] INFO JsonMessageProducer - Start producer loop 609 | Send messages to Kafka, use CTRL+D to exit gracefully. 610 | Type message> 611 | 612 | 3. Navigate your web browser to http://localhost:8080/messages.html 613 | 614 | 4. Type a message into the terminal. As soon as the Spring application has 615 | consumed the message from Kafka, the web page will display it. 616 | 617 | ### Show live view of measurements as they are consumed by Spring application 618 | 619 | 1. In one terminal, start the Spring boot application as described earlier. 620 | 621 | 2. In another terminal, start a measurement producer: 622 | 623 | ./run producer 624 | 625 | 3. Navigate your web browser to http://localhost:8080/measurements.html 626 | 627 | 4. Observe live as new measurement events are consumed by the Spring application 628 | and displayed on the page. New events are highlighted for a brief period to 629 | make them visually easier to distinguish. 630 | 631 | ### Experiment with Spring application 632 | 633 | In the previous scenario, try to artificically slow down the Spring application 634 | consumer and see what happens to the size of the batches that it consumes. To 635 | slow it down, start with the following arguments: 636 | 637 | ./boot-app --measurements.consumer.slowdown=6000 638 | 639 | This will make the Kakfa listener endpoint in 640 | `no.nav.kafka.sandbox.measurements.MeasurementsConsumer#receive` halt for 6 641 | seconds every time Spring invokes the method with incoming messages. This 642 | endpoint is setup with batching enabled, so you should see larger batches being 643 | processed, depending on amount of messages produced, and how slow the consumer 644 | is. 645 | 646 | The listener endpoint logs the size of batches it processes, so you will see it 647 | in the application log. By default, the listener endpoint is invoked by at most 648 | two Spring-kafka managed threads (each with their own `KafkaConsumer`). This is 649 | setup in `no.nav.kafka.sandbox.measurements.MeasurementsConfig`, locate line 650 | with `factory.setConcurrency(2);`. Do you think concurrency above 1 has any 651 | effect when the topic only has one partition `measurements-0` ? Inspect the 652 | thread-ids in the application log as it consumes messages, to determine if there 653 | is actually more than one thread invoking the listener method. 654 | 655 | To produce more messages in parallel, you can start more producers in the 656 | background: 657 | 658 | ./run producer &>/dev/null & 659 | 660 | As more producers start, you should notice the logged batch sizes increase, 661 | since volume of messages increases and the consumer is slowed down. (Note: to 662 | clean up producers running in the background, you can kill them with `kill 663 | $(jobs -p)`.) 664 | 665 | You could also produce 1000 messages with no delay, to really see batch size 666 | increase on the consumer side: 667 | 668 | ./run produce 1000 669 | 670 | Going further, you can test true parallel messages consumption in Spring, by 671 | changing the number of partitions on the `measurements` topic: 672 | 673 | 1. Stop Spring Boot application and any running command line producer/consumer 674 | clients. 675 | 676 | 2. Delete measurements topic: `./run deltopic measurements`. 677 | 678 | 3. Create new measurements topic with 4 partitions: `./run newtopic measurements 4` 679 | 680 | 4. Start Spring boot application as described earlier. 681 | 682 | 5. Start several producers in the background, as described earlier. 683 | 684 | 6. Watch Spring application log and notice that there are now two different 685 | thread ids invoking the listener endpoint in 686 | `no.nav.kafka.sandbox.measurements.MeasurementsConsumer#receive`. 687 | 688 | 689 | ### Experiment: default batch consumer error handling in Spring Kafka 690 | 691 | _For the following experiments, you should ensure the Spring Boot app is 692 | *stopped* before following the instructions._ 693 | 694 | #### Thoughts about error handling 695 | 696 | Error handling is important in a distributed asynchronous world. It can be 697 | difficult to get right, both because the various error situations can be 698 | complex, but also hard to reproduce or picture in advance. The typical result of 699 | poor or ignored error handling is growing inconsistencies between data in 700 | various systems. In others words, things will _eventually_ become _inconsistent_ 701 | intead of _consistent_ ! 702 | 703 | When you start tackling error handling, you should first have a very clear 704 | picture of the business requirements for your application. 705 | 706 | - Is it acceptable to process a single record multiple times if batch errors occur ? 707 | - Is it acceptable to lose records entirely ? 708 | - Is it necessary to preserve failed records, so that proper processing can be 709 | done at a later time (dead letter topic) ? 710 | - Is ordering important so that events happening after an error has occured 711 | (later offsets produced on a Kafka topic) must all wait to be processed until 712 | the error has been resolved ? 713 | 714 | There are several strategies that can be applied to error handling, and this 715 | guide only covers a small part of it, namely the core error handlers used by 716 | Spring Kafka consumers. Batch error handling is more difficult to get right than 717 | single message error handling, since errors typically affect all messages in a 718 | batch, regardless if just some of them are the cause of the failure. 719 | 720 | Spring Kafka has sophisticted support for various error handling strategies and 721 | one can easily become lost in options and configuration. Start with the simplest 722 | possible strategy, and keep it as simple as possible at all times, when 723 | implementing your business requirements. 724 | 725 | Topics such as outbox pattern, integrating Kafka with transactions and dead 726 | letter topics are not covered by this guide. 727 | 728 | #### On to experiment 729 | 730 | You can simulate failure to store events by adjusting the configuration property 731 | `measurements.event-store.failure-rate`. It can either be a floating point 732 | number between 0 and 1 that determines how often the store that the consumer 733 | saves events to should fail, randomly distributed. Or it can be specified as 734 | `F/T`, where `F` is the number of failures that should occur in a series, and 735 | `T` is the total number of stores before the this pattern repeats. A failed 736 | store will cause an exception to be thrown which will trigger Spring kafka 737 | consumer error handling. 738 | 739 | You can also simulate bad messages by using `./run null-producer` or 740 | `./run string-producer "{bad-json"` (will cause deserialization failure 741 | in Spring Boot app). 742 | 743 | 744 | Try this: 745 | 746 | 1. Ensure a producer for 'measurements' topic is running: 747 | 748 | ./run producer 749 | 750 | 2. Start Spring Boot app in another terminal with: 751 | 752 | ./boot-app --measurements.event-store.failure-rate=1 753 | 754 | Now all (100%) store operations will fail with `IOException`. See what happens 755 | in the application log. Error handling is all Spring Kafka defaults, which means 756 | that no particular error handler has been configured and the 757 | `FallbackBatchErrorHandler` will be used automatically by Spring. Does Spring 758 | commit offsets when exceptions occur in consumer ? In other words, does it 759 | progress beyond the point of failure and discard the failed records ? 760 | 761 | As we can see in the application log, for each failed record the consumer is 762 | invoked 10 times, then an ERROR is logged by Spring, telling us that the 763 | record(s) is discarded. So the default error handler retries 10 times before 764 | giving up. Then offsets are committed and so it progresses, and the record is 765 | lost. 766 | 767 | Let's try to determine the error resilience of the consumer using the default 768 | Spring error handler and no recovery options. 769 | 770 | 1. Ensure all producers and Spring Boot app are stopped. Clear measurements 771 | topic and produce exactly 100 records: 772 | 773 | ./run deltopic measurements 774 | ./run produce 100 775 | 776 | 2. Then start Spring Boot app and set event store failure rate to 5/10, meaning 5 777 | errors, then 5 successful stores, and so on: 778 | 779 | ./boot-app --measurements.event-store.failure-rate=5/10 780 | 781 | In the app logs you will notice that the consumer receives all 100 records per 782 | batch attempt, which is logged by `MeasurementsConsumer`. This happens multiple 783 | times until a message from the error handler logs that all 100 Kafka records 784 | have been discarded. Navigate to http://localhost:8080/measurements.html and 785 | check how many events where succesfully stored. How come there are exactly 5 786 | events that have been successfully stored ? Also, does Spring by default delay 787 | attempts to redeliver failed batches ? 788 | 789 | #### Explanation 790 | 791 | 1. Spring Kafka retries failed batches a limited number of times by default 792 | (with no delay). The first 5 attempts will all fail immediately, since this 793 | is how we have configured the event store to fail. 794 | 2. On the sixth attempt, the 5 first events from the batch will be stored 795 | successfully, then this batch will also fail. 796 | 3. On the last remaining 4 attempts, the batches will immediately fail on first 797 | store attempt again. 798 | 4. This gives a total of 5 successfully stored events. 799 | 800 | 801 | ### Logging and ignoring all errors 802 | 803 | You can use an error handler which ignores errors, does no retries, but still 804 | logs them: 805 | 806 | ./boot-app --measurements.event-store.failure-rate=1 --measurements.consumer.error-handler=log-and-ignore 807 | 808 | Start a producer and watch Spring Boot log. You'll see that batches are logged 809 | as errors, but never retried. Spring Kafka progresses; it logs the failed 810 | batches, skips them and commits offset. 811 | 812 | 813 | ### Experiment: batch consumer error handling in Spring Kafka: infinite retries 814 | 815 | Try this: 816 | 817 | 1. Start Spring Boot app with infinite retry error handler: 818 | 819 | ./boot-app --measurements.consumer.error-handler=infinite-retry 820 | 821 | 2. In another terminal, send a single null-message to the "measurements" topic: 822 | 823 | ./run null-producer measurements 824 | 825 | This message will fail in the consumer with a `NullPointerException`, since 826 | messages with a `null` value are not accepted by the consumer (although they are 827 | allowed by Kafka, and do not fail on JSON deserialization). 828 | 829 | Does Spring ever give up retrying the failing batch ? Watch the log from the 830 | Spring Boot app. It continues to try forever without really logging much. When 831 | quitting the app by pressing `CTRL+C`, Spring logs en ERROR that it was stopped 832 | while retry error handling was in progress. 833 | 834 | 835 | ### Experiment: batch consumer error handling in Spring Kafka: limited retries 836 | 837 | To control number of retries for failed messages when using batch consumer, you 838 | will typically configure a certain `BackOff` spec on the `DefaultErrorHandler`. 839 | This controls how many times processin of a batch will be retried and how long 840 | it should pause/backoff between attempts. 841 | 842 | Try this: 843 | 844 | 1. Clear topic measurements with `./run deltopic measurements`. 845 | 846 | 2. Produce 3 measurements to the topic: 847 | 848 | ./run produce 3 849 | 850 | 3. Send a poison pill `null` message to the measurements topic: 851 | 852 | ./run null-producer measurements 853 | 854 | 4. Send 3 more valid measurement events: 855 | 856 | ./run produce 3 857 | 858 | After quitting there will be 7 messages present on the topic, including the 859 | `null` message in the middle. 860 | 861 | 5. Start Spring Boot app with retrying error handler that should give up after 2 retries: 862 | 863 | ./boot-app --measurements.consumer.error-handler=retry-with-backoff 864 | 865 | Watch the logs. The batch is processed multiple times by `MeasurementsConsumer`, 866 | however it gives up after two retries, since the `null` message will cause a 867 | failure in the middle of the batch processing. Note that only the events 868 | processed before the poison pill in the batch are actually stored. We lost the 869 | messages coming after the poison `null` message in the batch entirely ! See if 870 | you can spot the end result by navigating to 871 | [http://localhost:8080/measurements.html](http://localhost:8080/measurements.html). 872 | You should only see the messages produced in step 2. (The producer PID which is 873 | printed to the console is part of the sensor-id for the measurement events, 874 | which allows you to correlate.) 875 | 876 | After giving up, the error handler logs an error and a list of discarded 877 | messages. But this error handler also allows us to configure a 878 | `ConsumerRecordRecoverer` which will be given the opportunity to recover 879 | messages, one by one, after all retry attempts have been exhausted. 880 | 881 | You can redo this experiment with the recovery option by running Spring boot app 882 | with error handler `retry-with-backoff-recovery` instead of 883 | `retry-with-backoff`. Are messages after the poision pill still lost ? See the 884 | code for this in `RetryingErrorHandler`, which is a custom extension of the 885 | Spring Kafka `DefaultErrorHandler`, but all it really does it set a recoverer 886 | and back-off-spec. 887 | 888 | If you can ensure your storage or processing is idempotent, you will be saving 889 | yourself some trouble in these situations, so that multiple writes of the same 890 | data is not a problem. The event store in the Kafka Sandbox Spring Boot app has 891 | this property. In other words, strive for "at least once" message processing 892 | semantics. 893 | 894 | 895 | ### Experiment: batch consumer error handling in Spring Kafka: recovery 896 | 897 | Notice in the code for our custom `RetryingErrorHandler` (enabled with 898 | `retry-with-backoff-recovery` from previous experiment) that it has to take care 899 | of storing valid events to the event store, which is also the main job of the 900 | consumer code. So there is a duplication of efforts there. We can actually just 901 | use the Spring Kafka `DefaultErrorHandler` with a recoverer, and make our batch 902 | consumer throw `BatchListenerFailedException`, instead of direct causes. This 903 | makes Spring aware of which record in a batch that failed, and so only the 904 | problematic records need recovery handling. 905 | 906 | Try this: 907 | 908 | 1. Go through steps 1-4 in the previous section, so that you end up with a 909 | poison pill null message in between other valid messages on the topic. 910 | 911 | 2. Start Spring Boot app with the recovering error handler and enable use of 912 | `BatchListenerFailedException` in consumer: 913 | 914 | ./boot-app --measurements.consumer.error-handler=recovering --measurements.consumer.useBatchListenerFailedException=true 915 | 916 | Notice in the the logs as the consumer first reports receiving the batch of 917 | messages. An exception is thrown because of the `null` message. The next time 918 | the consumer is invoked, it receives fewer messages in the batch, because the 919 | Spring error handler has automatically committed all messages up to, but not 920 | including, the failing message. So those previous messages need not be attempted 921 | again. 922 | 923 | It then tries to run the rest of the batch up until retries are exhausted, then 924 | it invokes the custom recovery handler. This recovery handler just logs that the 925 | null message is discarded. After that, the last messages in the batch are handed 926 | over to the consumer, which stores those successfully. 927 | 928 | End result: all valid messages that could be stored, have been stored, and the 929 | poison pill null message was simply skipped. Also, there is a performance 930 | benefit when comparing to the `RetryingErrorHandler`, since the valid messages 931 | are not written multiple times to the store during retries/recovery. 932 | 933 | 934 | ### What about transient failures when storing events ? 935 | 936 | If a valid message fails to be written into the event store, an `IOException` of 937 | some kind is typically thrown. This may be just a temporary condition, so it 938 | often makes sense to just retry until all messages in a batch are successfully 939 | written. The recovering error handler deals with this situation in exactly that 940 | way, by simply throwing an exception from the record recovery code. 941 | 942 | For this to work, the consumer listener must wrap exceptions in the Spring 943 | exception `BatchListenerFailedException` to communicate to the error handler 944 | which record in the batch failed. The error handler will take care of the rest. 945 | 946 | You can try this of course: 947 | 948 | 1. Ensure our test topic is empty with `./run deltopic measurements`. 949 | 950 | 2. Start Spring boot app with an event store that sometimes fail and using 951 | the recovering error handler: 952 | 953 | ./boot-app --measurements.consumer.error-handler=recovering --measurements.event-store.failure-rate=0.5 --measurements.consumer.useBatchListenerFailedException=true 954 | 955 | 3. Start a producer in another terminal: 956 | 957 | ./run producer 958 | 959 | 4. Let it run for a little while and watch Spring Boot app logs. You will see 960 | errors when event store fails. 961 | 962 | 5. Stop producer with `CTRL+C`, noting how many messages it sent to the Kafka 963 | topic (it is logged when it quits). Notice in Spring boot logs that it 964 | continues working on batches, which is also getting smaller each time, as 965 | more messages are *eventually written successfully* to the event store. 966 | 967 | 6. Navigate to http://localhost:8080/measurements.html and look at how many 968 | events have been successfully written to the event store. There should be 969 | *none missing*, even though the store failed half of the write attempts ! 970 | 971 | ### Handling failure to deserialize messages in batch consumer 972 | 973 | Since the deserialization step happens before our consumer listener gets the 974 | messages, it needs to be handled in a special way. Spring has designed the 975 | `ErrorHandlingDeserializer` Kafka deserializer for this purpose. It catches 976 | failed attempts at deserializing from a delegated deserializer class. 977 | 978 | By default, our Spring Boot app is setup to handle failures with deserializing 979 | values. 980 | 981 | Test it out: 982 | 983 | 1. Start Spring boot app: 984 | 985 | ./boot-app 986 | 987 | 2. Then send badly formatted data to the 'measurements' topic: 988 | 989 | ./run string-producer '}badjson' measurements 990 | 991 | Watch logs in Spring boot app. You'll see an error logged with information about 992 | the record that failed. This is accomplished by using Spring-Kafka 993 | `ErrorHandlingDeserializer` which delegates to the `JsonDeserializer`. Code for 994 | setting this up is found in `MeasurementsConfig`. Code for extracting the error 995 | in the batch listener can be found in `MeasurementsConsumer`. 996 | 997 | Now try disabling handling of deserialization errors: 998 | 999 | 1. Start Spring boot app: 1000 | 1001 | ./boot-app --measurements.consumer.handle-deserialization-error=false 1002 | 1003 | 2. Send badly formatted data to the 'measurements' topic: 1004 | 1005 | ./run string-producer '}badjson' measurements 1006 | 1007 | You will notice that the Spring boot app now behaves in an undesirable way, both 1008 | because it will never progress past the bad record (unless it is unassigned from 1009 | the topic-partition and another consumer in the same group picks up the bad 1010 | record), and because it does no backoff delaying with the default error handler, 1011 | so the listener container keeps failing over and over rapidly. 1012 | 1013 | To improve things slightly, you can select an error handler that does 1014 | backoff-delaying, like `recovering`: 1015 | 1016 | ./boot-app --measurements.consumer.handle-deserialization-error=false --measurements.consumer.error-handler=recovering 1017 | 1018 | It will not be able to progress beyond the error, but at least it does delay 1019 | between attempts to avoid flodding logs and using up a lot of resources. 1020 | 1021 | The best practice is to actually handle deserialization errors, like 1022 | demonstrated earlier. Or you will have to write a custom error handler which can 1023 | deal with `SerializationException`, which the `DefaultErrorHandler` cannot 1024 | process. 1025 | 1026 | ### Stopping consumer on fatal errors 1027 | 1028 | Sometimes an error is definitively not recoverable and some form of manual 1029 | intervention or application restart is required. An example may be that the 1030 | consumer is not authorized to write data to the target data store due to invalid 1031 | credentials. In such cases you can look to `CommonContainerStoppingErrorHandler` 1032 | in Spring Kafka, which by default simply shuts down the consumer on any error 1033 | thrown from the batch processing in the listener. By extending this class, you 1034 | can test the type of error and delegate to the super class if the error is 1035 | deemed fatal. 1036 | 1037 | To see how an *unmodified* `CommonContainerStoppingErrorHandler` works, you can 1038 | do the following: 1039 | 1040 | 1. Ensure a poison pill message is present on topic: 1041 | 1042 | ./run null-producer 1043 | 1044 | 2. Start a regular producer and let it run: 1045 | 1046 | ./run producer 1047 | 1048 | 2. Start Spring boot app with `stop-container` error handler: 1049 | 1050 | ./boot-app --measurements.consumer.error-handler=stop-container 1051 | 1052 | Check logs. You will see that as soon as the posion pill message is encountered 1053 | the error handler kicks into action and stops the Spring message listener 1054 | container. Then all activity stops and the topic-partition is unassigned from 1055 | the consumer. As this app does not provide any way of re-starting the consumer, 1056 | the app itself must be restarted to try again. 1057 | 1058 | 1059 | ### Further reading and experimentation 1060 | 1061 | You can investigate and modify code in `MeasurementsConfig` and in package 1062 | `no.nav.kafka.sandbox.measurements.errorhandlers` to experiment further. Spring 1063 | has a large number of options and customizability with regard to error handling 1064 | in Kafka consumers (see `CommonErrorHandler` and all of its sub-types). 1065 | 1066 | Also see https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html#error-handlers 1067 | 1068 | 1069 | ## Tuning logging to get more details 1070 | 1071 | If you would like to see the many technical details that the Kafka clients emit, 1072 | you can set the log level of the Apache Kafka clients in the file 1073 | `src/main/resources/simplelogger.properties`. It is by default `WARN`, but 1074 | `INFO` will output much more information. For the Spring Boot application, 1075 | logging setup is very standard and can be adjusted according to Spring 1076 | documentation. 1077 | 1078 | 1079 | ## Unit/integration tests with `DockerComposeEnv` 1080 | 1081 | The class `DockerComposeEnv` can be used to manage a temporary docker-compose 1082 | environment for unit tests. It makes it simple to bring up/down 1083 | docker-compose-configurations between tests and handles port assignments and 1084 | other boring details automatically. It also ensures that the Docker resources 1085 | have unique names that should not conflict with other containers. It requires 1086 | the `docker compose` command to function, but has no other dependencies. A basic 1087 | compose configuration for Kafka services is stored in 1088 | `src/test/resources/DockerComposeKafkaEnv.yml`, which is used by 1089 | `KafkaSandboxTest`. 1090 | 1091 | See example of usage in `KafkaSandboxTest`. 1092 | 1093 | ## Tips to clean up ephemeral Docker containers and networks 1094 | 1095 | When developing unit tests, sometimes things go awry and the Docker containers 1096 | comprising the temporary Kafka environments are not cleaned up. Here are a few 1097 | tips to keep things tidy and free resources. 1098 | 1099 | To stop and erase all `KafkaDockerComposeEnv`-created Docker containers and 1100 | networks, use the following commands: 1101 | 1102 | docker rm -fv $(docker ps -aq -f name=broker-test- -f name=zookeeper-test-) 1103 | docker network rm $(docker network ls -f name=kafkadockercomposeenv -q) 1104 | 1105 | 1106 | ## Using kafkacat to inspect Kafka topics 1107 | 1108 | When working with Kafka, a very useful command line tool is 1109 | [kafkacat](https://github.com/edenhill/kafkacat). It is a light weight, but 1110 | powerful Kafka client that supports many options. 1111 | 1112 | A typical installation on Debian-ish Linux can be accomplished with: 1113 | 1114 | sudo apt install kafkacat 1115 | 1116 | Or Mac: 1117 | 1118 | brew install kafkacat 1119 | 1120 | A tool which demonstrates use of kafkacat is included in the source code of this 1121 | repository. It can be found `clients-kafkacat/topic-tail` and is a "tail"-like 1122 | command to show metadata for the latest records on a Kafka topic. It requires 1123 | Python 3.5+ and kafkacat to run. 1124 | 1125 | Try it out: 1126 | 1127 | 1. Ensure measurements producer is running: `./run producer` 1128 | 1129 | 2. Tail topic example: 1130 | 1131 | $ clients-kafkacat/topic-tail -f measurements 1132 | [measurements-0] key sensor-9950, sz 127, ts 2020-10-06T22:50:53.755, offset 0 1133 | [measurements-0] key sensor-9950, sz 127, ts 2020-10-06T22:50:55.633, offset 1 1134 | [measurements-0] key sensor-9950, sz 127, ts 2020-10-06T22:50:57.608, offset 2 1135 | 1136 | 1137 | ## Using official Kafka command line tools 1138 | 1139 | You can connect to the Docker container running the Kafka broker and get access 1140 | to some interesting command line tools: 1141 | 1142 | docker exec -it broker /bin/bash -i 1143 | 1144 | Then type "kafka-" and press TAB a couple of times to find the commands 1145 | available. 1146 | 1147 | ## Ending words 1148 | 1149 | Hopefully you will have learned a thing or two by following this guide. Any 1150 | feedback, suggestions, bug reports, etc. can be directed at the author: oyvind@stegard.net 1151 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /boot-app: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")" 5 | 6 | if ! test -f messages/target/messages-*.jar -a\ 7 | -f clients-spring/target/clients-spring-*.jar; then 8 | mvn -B install 9 | fi 10 | 11 | cd clients-spring 12 | exec java -jar target/clients-spring-*.jar "$@" 13 | 14 | -------------------------------------------------------------------------------- /clients-kafkacat/topic-tail: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Simple topic-tail tool which demonstrates usage of kafkacat. 3 | # Only for display of record metadata. Formats timetamps in human readable form. 4 | # By default tails the topic, showing only the N latest records. 5 | 6 | VERSION = '0.2' 7 | 8 | import subprocess 9 | import sys 10 | import shutil 11 | import os 12 | from datetime import datetime 13 | import re 14 | 15 | def check_kafkacat(): 16 | if not shutil.which('kafkacat'): 17 | sys.stderr.write('Error: missing "kafkacat" command in system PATH\n') 18 | sys.exit(255) 19 | 20 | def iso_from_ts(ts_epoch_millis): 21 | return datetime.fromtimestamp(int(ts_epoch_millis)/1000).isoformat(timespec='milliseconds') 22 | 23 | def kafkacat_list_topics(broker): 24 | check_kafkacat() 25 | 26 | with subprocess.Popen(['kafkacat', '-b', broker, '-L'], 27 | env={**os.environ.copy(), 'LC_MESSAGES':'C.UTF-8'}, 28 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, 29 | universal_newlines=True) as kcproc: 30 | 31 | topicpattern = re.compile(r'\s*topic "([^"]+)" with (\d+) partitions:') 32 | buf = [] 33 | for line in kcproc.stdout: 34 | matchtopic = topicpattern.match(line) 35 | if matchtopic: 36 | topic = matchtopic.group(1) 37 | partitions = matchtopic.group(2) 38 | buf.append('{:<50} ({} partitions)'.format(topic, partitions)) 39 | 40 | buf.sort() 41 | for line in buf: print(line) 42 | 43 | def kafkacat_tail(broker, topic, n, follow, offset): 44 | check_kafkacat() 45 | 46 | with subprocess.Popen(['kafkacat', '-C', '-b', broker, '-t', topic, '-Z', '-u', 47 | '-f', '%t\t%p\t%k\t%S\t%T\t%o\n'] 48 | + (['-e'] if not follow else []) 49 | + (['-o', offset] if offset else []), 50 | env={**os.environ.copy(), 'LC_MESSAGES':'C.UTF-8'}, 51 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, 52 | universal_newlines=True) as kcproc: 53 | 54 | buf = [] 55 | endpattern = re.compile(r'^% Reached end.*at offset (\d+)[^\d]*.*') 56 | end_offset = -1 57 | for line in kcproc.stdout: 58 | if line.find('% ERROR') == 0: 59 | sys.stderr.write(line) 60 | sys.exit(1) 61 | 62 | is_end_message = endpattern.match(line) 63 | if is_end_message: 64 | end_offset = int(is_end_message.group(1)) 65 | else: 66 | t, p, k, sz, ts, o = line.strip('\n').split('\t', 5) 67 | buf.append('[{}-{}] key {}, sz {:>5}, ts {}, offset {}'.format(t, p, k, sz, iso_from_ts(ts), o)) 68 | if n > -1 and len(buf) > n: 69 | buf = buf[len(buf)-n:] 70 | 71 | if n < 0 or end_offset > -1: 72 | for msg in buf: print(msg) 73 | buf = [] 74 | 75 | if __name__ == '__main__': 76 | 77 | import argparse 78 | parser = argparse.ArgumentParser( 79 | description='Simple "tail" for Kafka topic records metadata. Keys are displayed as part of metadata and cannot be binary. Values are not displayed at all by this tool. Requires "kafkacat" command in PATH.', 80 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 81 | parser.add_argument('-b', '--broker', type=str, 82 | default='localhost:9092', help='Kafka broker :') 83 | parser.add_argument('-f', '--follow', action='store_true', default=False, 84 | help='Keep following topic after reaching end (use CTRL+C to exit).') 85 | parser.add_argument('-n', type=int, help='Tail last N records from topic, or use negative number show all consumed records', default=10) 86 | parser.add_argument('-o', '--offset', type=str, help='Pass on to kafkacat --offset option, for instance "--offset beginning" to seek to start of topic before tailing') 87 | parser.add_argument('-l', '--list', action='store_true', help='List topics available in broker') 88 | parser.add_argument('-v', '--version', action='version', version='%(prog)s '+VERSION) 89 | parser.add_argument('topic', metavar='TOPIC', nargs='?', type=str) 90 | args = parser.parse_args() 91 | 92 | if args.list: 93 | kafkacat_list_topics(args.broker) 94 | elif args.topic: 95 | kafkacat_tail(args.broker, args.topic, args.n, args.follow, args.offset) 96 | else: 97 | sys.stderr.write('Error: a TOPIC required\n') 98 | parser.print_help() 99 | 100 | -------------------------------------------------------------------------------- /clients-spring/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | no.nav.kafka 9 | kafka-sandbox 10 | 1.0-SNAPSHOT 11 | 12 | 13 | clients-spring 14 | 15 | 16 | 17 | no.nav.kafka 18 | messages 19 | ${project.version} 20 | 21 | 22 | 23 | org.springframework.boot 24 | spring-boot-starter-web 25 | 26 | 27 | 28 | org.springframework.kafka 29 | spring-kafka 30 | 31 | 32 | 33 | org.slf4j 34 | slf4j-api 35 | 36 | 37 | 38 | org.springframework.boot 39 | spring-boot-starter-test 40 | test 41 | 42 | 43 | 44 | 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-maven-plugin 49 | 50 | 51 | repackage 52 | 53 | repackage 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | open module clients.spring { 2 | requires no.nav.kafka.sandbox.messages; 3 | requires spring.boot; 4 | requires org.apache.tomcat.embed.core; // This is required for auto configuration of spring web to work with modular app.. 5 | requires spring.boot.autoconfigure; 6 | requires spring.context; 7 | requires spring.core; 8 | requires spring.web; 9 | requires spring.kafka; 10 | requires spring.beans; 11 | requires kafka.clients; 12 | requires com.fasterxml.jackson.databind; 13 | requires org.slf4j; 14 | } 15 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/Application.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class Application { 8 | 9 | public static void main(String... args) { 10 | SpringApplication.run(Application.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/consolemessages/ConsoleMessagesConfig.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.consolemessages; 2 | 3 | import no.nav.kafka.sandbox.data.DefaultEventStore; 4 | import no.nav.kafka.sandbox.data.EventStore; 5 | import no.nav.kafka.sandbox.messages.ConsoleMessages; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | 10 | @Configuration 11 | public class ConsoleMessagesConfig { 12 | 13 | /** 14 | * In-memory store of console messages received from Kafka. 15 | */ 16 | @Bean 17 | public EventStore messageEventStore(@Value("${consolemessages.event-store.max-size:200}") int maxSize) { 18 | return new DefaultEventStore<>(maxSize, false); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/consolemessages/ConsoleMessagesConsumer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.consolemessages; 2 | 3 | import no.nav.kafka.sandbox.data.EventStore; 4 | import no.nav.kafka.sandbox.messages.ConsoleMessages; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.kafka.annotation.KafkaListener; 8 | import org.springframework.stereotype.Component; 9 | 10 | /** 11 | * Demonstrates simple configuration of a consumer using only defaults from Spring auto-configuration and external 12 | * configuration file src/main/resources/application.yml. Annotation overrides the minimal set of properties 13 | * to allow proper deserialization of the incoming message type. 14 | * 15 | *

The consumer can only process one record at a time.

16 | */ 17 | @Component 18 | public class ConsoleMessagesConsumer { 19 | 20 | private final EventStore store; 21 | 22 | private static final Logger LOG = LoggerFactory.getLogger(ConsoleMessagesConsumer.class); 23 | 24 | public ConsoleMessagesConsumer(EventStore messageEventStore) { 25 | this.store = messageEventStore; 26 | } 27 | 28 | @KafkaListener( 29 | topics = "${consolemessages.consumer.topic}", 30 | properties = {"spring.json.value.default.type=no.nav.kafka.sandbox.messages.ConsoleMessages.Message"}) 31 | public void receiveMessage(ConsoleMessages.Message message) { 32 | if (message == null) { 33 | throw new NullPointerException("Message was null"); 34 | } 35 | 36 | LOG.info("Received a console message from {}", message.senderId); 37 | store.storeEvent(message); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/consolemessages/ConsoleMessagesRestController.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.consolemessages; 2 | 3 | import no.nav.kafka.sandbox.data.EventStore; 4 | import no.nav.kafka.sandbox.messages.ConsoleMessages; 5 | import org.springframework.http.MediaType; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.RestController; 8 | 9 | import java.util.Collections; 10 | import java.util.List; 11 | 12 | @RestController 13 | public class ConsoleMessagesRestController { 14 | 15 | private final EventStore messageStore; 16 | 17 | public ConsoleMessagesRestController(EventStore messageEventStore) { 18 | this.messageStore = messageEventStore; 19 | } 20 | 21 | /** 22 | * Get messages ordered ascending by time of reception. 23 | */ 24 | @GetMapping(value = "/messages/api", produces = MediaType.APPLICATION_JSON_VALUE) 25 | public List getMessages() { 26 | return messageStore.fetchEvents(); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/data/DefaultEventStore.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.data; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Deque; 5 | import java.util.LinkedList; 6 | import java.util.List; 7 | import java.util.function.Predicate; 8 | 9 | public class DefaultEventStore implements EventStore { 10 | 11 | private final Deque events = new LinkedList<>(); 12 | private final int maxSize; 13 | private final boolean failOnMaxSize; 14 | 15 | public DefaultEventStore(int maxSize, boolean failOnMaxSize) { 16 | this.maxSize = maxSize; 17 | this.failOnMaxSize = failOnMaxSize; 18 | } 19 | 20 | @Override 21 | public synchronized boolean storeEvent(T event) { 22 | if (events.contains(event)) { 23 | return false; 24 | } 25 | while (events.size() >= maxSize) { 26 | if (failOnMaxSize) { 27 | throw new IllegalStateException("Store has reached max capacity of " + events.size() + " events"); 28 | } 29 | events.removeFirst(); 30 | } 31 | events.add(event); 32 | return true; 33 | } 34 | 35 | @Override 36 | public synchronized boolean removeIf(Predicate p) { 37 | return events.removeIf(p); 38 | } 39 | 40 | @Override 41 | public synchronized List fetchEvents() { 42 | return new ArrayList<>(events); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/data/EventStore.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.data; 2 | 3 | import java.util.List; 4 | import java.util.function.Predicate; 5 | 6 | 7 | /** 8 | * 9 | * @param some type of event, preferably an immutable type. 10 | */ 11 | public interface EventStore { 12 | 13 | /** 14 | * 15 | * @param event an immutable event object 16 | * @return {@code true} if event was stored, {@code false} if it already existed 17 | */ 18 | boolean storeEvent(T event); 19 | 20 | /** 21 | * @return all events from oldest to most recently added. 22 | */ 23 | List fetchEvents(); 24 | 25 | /** 26 | * Removes all stored events where predicate is {@code true}. 27 | * @return {@code true} if at least one element was removed 28 | */ 29 | boolean removeIf(Predicate p); 30 | 31 | } 32 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/data/EventStoreWithFailureRate.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.data; 2 | 3 | import java.util.Objects; 4 | import java.util.Random; 5 | import java.util.concurrent.atomic.AtomicInteger; 6 | import java.util.function.Function; 7 | import java.util.regex.Matcher; 8 | import java.util.regex.Pattern; 9 | 10 | /** 11 | *

Simulate event store that sometimes fails to store values, depending on configured failure rate. 12 | * 13 | *

Can be used to test error handling in Spring Kafka.

14 | * 15 | * @param store element type 16 | */ 17 | public class EventStoreWithFailureRate extends DefaultEventStore { 18 | 19 | private final FailureRateStrategy failureRate; 20 | private final Function exceptionSupplier; 21 | 22 | /** 23 | * 24 | * @param maxSize maximum size of event store, oldest value is discarded when number of elements goes beyond limit. 25 | * @param failureRate a pluggable strategy implementing how often to fail store calls 26 | * @param exceptionSupplier code to create a synthentic exception to throw when failure should occur. This function 27 | * will be provided with the value to store as argument and should produce a {@code Throwable} of some type. 28 | */ 29 | public EventStoreWithFailureRate(int maxSize, boolean failOnMaxSize, FailureRateStrategy failureRate, Function exceptionSupplier) { 30 | super(maxSize, failOnMaxSize); 31 | this.failureRate = failureRate; 32 | this.exceptionSupplier = Objects.requireNonNull(exceptionSupplier, "exceptionSupplier cannot be null"); 33 | } 34 | 35 | @Override 36 | public synchronized boolean storeEvent(T event) { 37 | if (failureRate.failureFor(event)) { 38 | sneakyThrow(exceptionSupplier.apply(event)); 39 | } 40 | 41 | return super.storeEvent(event); 42 | } 43 | 44 | // https://stackoverflow.com/questions/14038649/java-sneakythrow-of-exceptions-type-erasure 45 | private static RuntimeException sneakyThrow(Throwable e) throws E { 46 | throw (E)e; 47 | } 48 | 49 | @FunctionalInterface 50 | public interface FailureRateStrategy { 51 | 52 | boolean failureFor(T event); 53 | 54 | } 55 | 56 | public static class FixedFailSuccessCountPattern implements FailureRateStrategy { 57 | 58 | private final int failCount; 59 | private final int totalCount; 60 | private final AtomicInteger counter = new AtomicInteger(0); 61 | 62 | private FixedFailSuccessCountPattern(int failCount, int totalCount) { 63 | if (failCount > totalCount) { 64 | throw new IllegalArgumentException("fail count cannot be bigger than total count"); 65 | } 66 | this.failCount = failCount; 67 | this.totalCount = totalCount; 68 | } 69 | 70 | @Override 71 | public boolean failureFor(T t) { 72 | final int value = counter.getAndIncrement() % totalCount; 73 | return value < failCount; 74 | } 75 | 76 | public static FixedFailSuccessCountPattern fromSpec(String spec) { 77 | Matcher matcher = Pattern.compile("([0-9]+)\\s*/\\s*([0-9]+)").matcher(spec); 78 | if (!matcher.find()) { 79 | throw new IllegalArgumentException("Failure rate expression should be on the form 'x/y', " 80 | + "where x is number of times to fail and y is total number of times for a cycle."); 81 | } 82 | int failCount = Math.max(0 ,Integer.parseInt(matcher.group(1))); 83 | int totalCount = Math.max(0, Integer.parseInt(matcher.group(2))); 84 | 85 | return new FixedFailSuccessCountPattern(failCount, totalCount); 86 | } 87 | 88 | } 89 | 90 | public static class AverageRatioRandom implements FailureRateStrategy { 91 | 92 | private final float failureRate; 93 | private final Random random = new Random(); 94 | 95 | public AverageRatioRandom(float failureRate) { 96 | if (failureRate < 0 || failureRate > 1.0) { 97 | throw new IllegalArgumentException("failure rate must be a decimal number between 0.0 and 1.0"); 98 | } 99 | this.failureRate = failureRate; 100 | } 101 | 102 | @Override 103 | public boolean failureFor(T event) { 104 | return random.nextFloat() < failureRate; 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/measurements/MeasurementsConfig.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.measurements; 2 | 3 | import no.nav.kafka.sandbox.data.DefaultEventStore; 4 | import no.nav.kafka.sandbox.data.EventStore; 5 | import no.nav.kafka.sandbox.data.EventStoreWithFailureRate; 6 | import no.nav.kafka.sandbox.data.EventStoreWithFailureRate.FixedFailSuccessCountPattern; 7 | import no.nav.kafka.sandbox.measurements.errorhandlers.RecoveringErrorHandler; 8 | import no.nav.kafka.sandbox.measurements.errorhandlers.RetryingErrorHandler; 9 | import no.nav.kafka.sandbox.messages.Measurements.SensorEvent; 10 | import org.apache.kafka.clients.consumer.ConsumerConfig; 11 | import org.apache.kafka.common.serialization.StringDeserializer; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 16 | import org.springframework.context.annotation.Bean; 17 | import org.springframework.context.annotation.Configuration; 18 | import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; 19 | import org.springframework.kafka.core.ConsumerFactory; 20 | import org.springframework.kafka.core.DefaultKafkaConsumerFactory; 21 | import org.springframework.kafka.listener.*; 22 | import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; 23 | import org.springframework.kafka.support.serializer.JsonDeserializer; 24 | import org.springframework.util.backoff.FixedBackOff; 25 | 26 | import java.io.IOException; 27 | import java.util.HashMap; 28 | import java.util.Map; 29 | import java.util.Optional; 30 | import java.util.function.Function; 31 | 32 | /** 33 | * Example of multi threaded Kafka consumer setup using Spring, with manual setup of container listener factory and 34 | * consumer factory. 35 | * 36 | *

The configuration of Kafka consumer aspects is based on config from {@code src/main/resources/application.yml} with specific 37 | * overrides done manually in this configuration class.

38 | * 39 | *

Error handling strategies can be customized by setting value of property measurements.consumer.error-handler

. 40 | */ 41 | @Configuration 42 | public class MeasurementsConfig { 43 | 44 | private static final Logger LOG = LoggerFactory.getLogger(MeasurementsConfig.class); 45 | 46 | /** 47 | * Customize different exceptions to throw depending on event to store. 48 | */ 49 | @Bean 50 | public Function fakeExceptionSupplier() { 51 | return event -> new IOException("Failed to store event " + event.toString() + ", general I/O failure"); 52 | } 53 | 54 | /** 55 | * In-memory store of measurement events received from Kafka. 56 | */ 57 | @Bean 58 | public EventStore sensorEventStore(@Value("${measurements.event-store.max-size:200}") int maxSize, 59 | @Value("${measurements.event-store.failure-rate:0.0}") String failureRateSpec, 60 | @Value("${measurements.event-store.fail-on-max-size:false}") boolean failOnMaxSize, 61 | Function exceptionSupplier) { 62 | if (failureRateSpec.contains("/")) { 63 | return new EventStoreWithFailureRate<>(maxSize, failOnMaxSize, 64 | FixedFailSuccessCountPattern.fromSpec(failureRateSpec), exceptionSupplier); 65 | } 66 | 67 | float failureRate = Float.parseFloat(failureRateSpec); 68 | 69 | if (failureRate > 0) { 70 | return new EventStoreWithFailureRate<>(maxSize, failOnMaxSize, 71 | new EventStoreWithFailureRate.AverageRatioRandom(failureRate), exceptionSupplier); 72 | } else { 73 | return new DefaultEventStore<>(maxSize, failOnMaxSize); 74 | } 75 | } 76 | 77 | /** 78 | * @return a Kafka listener container, which can be referenced by name from listener endpoints. 79 | */ 80 | @Bean 81 | public ConcurrentKafkaListenerContainerFactory measurementsListenerContainer( 82 | ConsumerFactory consumerFactory, 83 | Optional errorHandler, 84 | @Value("${measurements.consumer.handle-deserialization-error:true}") boolean handleDeserializationError) { 85 | 86 | // Consumer configuration from application.yml, where we will override some properties here: 87 | Map externalConfigConsumerProps = new HashMap<>(consumerFactory.getConfigurationProperties()); 88 | 89 | ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); 90 | factory.setConsumerFactory(consumerFactory(externalConfigConsumerProps, handleDeserializationError)); 91 | factory.setConcurrency(2); // Decrease/increase to observe how many threads are invoking the listener endpoint with message batches 92 | factory.setAutoStartup(true); 93 | factory.setBatchListener(true); 94 | 95 | // See https://docs.spring.io/spring-kafka/reference/html/#committing-offsets: 96 | // BATCH is default, but included here for clarity. It controls how offsets are commited back to Kafka. 97 | // Spring will commit offsets when a batch listener has completed processing without any exceptions being thrown. 98 | factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.BATCH); 99 | 100 | if (errorHandler.isPresent()) { 101 | LOG.info("Using error handler: {}", errorHandler.map(h -> h.getClass().getSimpleName()).orElse("none")); 102 | factory.setCommonErrorHandler(errorHandler.get()); 103 | } else { 104 | LOG.info("Using Spring Kafka default error handler"); 105 | } 106 | 107 | if (handleDeserializationError) { 108 | LOG.info("Will handle value deserialization errors."); 109 | } else { 110 | LOG.info("Value deserialization errors are not handled explicitly."); 111 | } 112 | 113 | return factory; 114 | } 115 | 116 | private DefaultKafkaConsumerFactory consumerFactory( 117 | Map externalConfigConsumerProps, 118 | boolean handleDeserializationError) { 119 | // override some consumer props from external config 120 | Map consumerProps = new HashMap<>(externalConfigConsumerProps); 121 | consumerProps.put(ConsumerConfig.CLIENT_ID_CONFIG, "boot-app-measurement"); 122 | consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "boot-app-measurement"); 123 | 124 | // Deserialization config 125 | consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); 126 | consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); 127 | consumerProps.put(JsonDeserializer.VALUE_DEFAULT_TYPE, SensorEvent.class.getName()); 128 | 129 | if (handleDeserializationError) { 130 | consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); 131 | consumerProps.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName()); 132 | } 133 | 134 | return new DefaultKafkaConsumerFactory<>(consumerProps); 135 | } 136 | 137 | @Bean 138 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "log-and-ignore") 139 | public CommonErrorHandler ignoreHandler() { 140 | return new CommonLoggingErrorHandler(); 141 | } 142 | 143 | @Bean 144 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "infinite-retry") 145 | public CommonErrorHandler infiniteRetryHandler() { 146 | return new DefaultErrorHandler(new FixedBackOff(FixedBackOff.DEFAULT_INTERVAL, FixedBackOff.UNLIMITED_ATTEMPTS)); 147 | } 148 | 149 | @Bean 150 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "retry-with-backoff") 151 | public CommonErrorHandler retryWithBackoffHandler() { 152 | return new DefaultErrorHandler(new FixedBackOff(2000L, 2)); 153 | } 154 | 155 | @Bean 156 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "retry-with-backoff-recovery") 157 | public CommonErrorHandler retryWithBackoffRecoveryHandler(EventStore eventStore) { 158 | return new RetryingErrorHandler(eventStore); 159 | } 160 | 161 | @Bean 162 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "recovering") 163 | public CommonErrorHandler recoveringHandler() { 164 | return new RecoveringErrorHandler(); 165 | } 166 | 167 | @Bean 168 | @ConditionalOnProperty(value = "measurements.consumer.error-handler", havingValue = "stop-container") 169 | public CommonErrorHandler containerStoppingHandler() { 170 | return new CommonContainerStoppingErrorHandler(); 171 | } 172 | 173 | } 174 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/measurements/MeasurementsConsumer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.measurements; 2 | 3 | import no.nav.kafka.sandbox.data.EventStore; 4 | import no.nav.kafka.sandbox.messages.Measurements; 5 | import org.apache.kafka.clients.consumer.ConsumerRecord; 6 | import org.apache.kafka.common.header.Header; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.kafka.annotation.KafkaListener; 11 | import org.springframework.kafka.listener.BatchListenerFailedException; 12 | import org.springframework.kafka.support.serializer.DeserializationException; 13 | import org.springframework.kafka.support.serializer.SerializationUtils; 14 | import org.springframework.stereotype.Component; 15 | 16 | import java.io.ByteArrayInputStream; 17 | import java.io.ObjectInputStream; 18 | import java.util.List; 19 | import java.util.Optional; 20 | import java.util.concurrent.TimeUnit; 21 | 22 | /** 23 | * Example of more advanced multi threaded Kafka consumer setup using Spring. The listener endpoint here references a custom built 24 | * {@link org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory} in {@link MeasurementsConfig} by 25 | * use of annotation attribute containerFactory. 26 | * 27 | *

Demonstrates listener endpoint receiving batches of consumer records.

28 | * 29 | *

You can increase slowdown to get larger batches each time the endpoint is called.

30 | * 31 | */ 32 | @Component 33 | public class MeasurementsConsumer { 34 | 35 | private final EventStore eventStore; 36 | 37 | private static final Logger LOG = LoggerFactory.getLogger(MeasurementsConsumer.class); 38 | 39 | private final long slowdownMillis; 40 | 41 | private final boolean useBatchListenerFailedException; 42 | 43 | public MeasurementsConsumer(EventStore store, 44 | @Value("${measurements.consumer.slowdown:0}") long slowdownMillis, 45 | @Value("${measurements.consumer.useBatchListenerFailedException:false}") boolean useBatchListenerFailedException) { 46 | this.eventStore = store; 47 | this.slowdownMillis = slowdownMillis; 48 | this.useBatchListenerFailedException = useBatchListenerFailedException; 49 | } 50 | 51 | /** 52 | * More Kafka config in {@link MeasurementsConfig}. 53 | * @param records 54 | */ 55 | @KafkaListener(topics = "${measurements.consumer.topic}", containerFactory = "measurementsListenerContainer") 56 | public void receive(List> records) { 57 | LOG.info("Received list of {} Kafka consumer records", records.size()); 58 | 59 | if (slowdownMillis > 0) { 60 | try { 61 | TimeUnit.MILLISECONDS.sleep(slowdownMillis); 62 | } catch (InterruptedException ie) {} 63 | } 64 | 65 | records.forEach(record -> { 66 | if (checkFailedDeserialization(record)) return; 67 | 68 | if (record.value() == null) { 69 | NullPointerException businessException = new NullPointerException("Message at " 70 | + record.topic() + "-" + record.partition() + ":" + record.offset() + " with key " + record.key() + " was null"); 71 | 72 | if (useBatchListenerFailedException) { 73 | // Communicate to error handler which record in the batch that failed, and the root cause 74 | throw new BatchListenerFailedException(businessException.getMessage(), businessException, record); 75 | } else { 76 | // Throw raw root cause for other types of error handling 77 | throw businessException; 78 | } 79 | } 80 | 81 | try { 82 | eventStore.storeEvent(record.value()); 83 | } catch (Exception e) { 84 | if (useBatchListenerFailedException) { 85 | // Communicate to error handler which record in the batch that failed, and the root cause 86 | throw new BatchListenerFailedException(e.getMessage(), e, record); 87 | } else { 88 | throw e; 89 | } 90 | } 91 | }); 92 | } 93 | 94 | private boolean checkFailedDeserialization(ConsumerRecord record) { 95 | return failedValueDeserialization(record).map(e -> { 96 | LOG.error("Message value at {}-{} offset {} failed to deserialize: {}: {}, skipping it.", 97 | record.topic(), record.partition(), record.offset(), 98 | e.getClass().getSimpleName(), e.getMessage()); 99 | return true; 100 | }).orElse(false); 101 | } 102 | 103 | 104 | private static Optional failedValueDeserialization(ConsumerRecord record) { 105 | Header valueDeserializationError = record.headers().lastHeader(SerializationUtils.VALUE_DESERIALIZER_EXCEPTION_HEADER); 106 | if (valueDeserializationError != null) { 107 | try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(valueDeserializationError.value()))){ 108 | DeserializationException dex = (DeserializationException)ois.readObject(); 109 | return Optional.ofNullable(dex); 110 | } catch (Exception e) { 111 | LOG.error("Failed to read header containing deserialization exception information at {}-{}, offset {}", 112 | record.topic(), record.partition(), record.offset()); 113 | } 114 | } 115 | 116 | return Optional.empty(); 117 | } 118 | 119 | 120 | } 121 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/measurements/MeasurementsRestController.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.measurements; 2 | 3 | import no.nav.kafka.sandbox.data.EventStore; 4 | import no.nav.kafka.sandbox.messages.Measurements; 5 | import org.springframework.format.annotation.DateTimeFormat; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestParam; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | import java.time.OffsetDateTime; 12 | import java.util.List; 13 | 14 | @RestController 15 | public class MeasurementsRestController { 16 | 17 | private final EventStore eventStore; 18 | 19 | public MeasurementsRestController(EventStore sensorEventStore) { 20 | this.eventStore = sensorEventStore; 21 | } 22 | 23 | /** 24 | * @param after only include messages with a timestamp later than this value 25 | * @return messages from most oldest to most recent, optionally filtering by timestamp. 26 | */ 27 | @GetMapping(path = "/measurements/api", produces = MediaType.APPLICATION_JSON_VALUE) 28 | public List getMeasurements(@RequestParam(value = "after", required = false, defaultValue = "1970-01-01T00:00Z") 29 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime after) { 30 | 31 | return eventStore.fetchEvents().stream() 32 | .filter(e -> e.getTimestamp().isAfter(after)) 33 | .toList(); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/measurements/errorhandlers/RecoveringErrorHandler.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.measurements.errorhandlers; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.kafka.listener.DefaultErrorHandler; 6 | import org.springframework.kafka.listener.ListenerExecutionFailedException; 7 | import org.springframework.util.backoff.FixedBackOff; 8 | 9 | /** 10 | * This error handler does not recover anything more than exactly failed records 11 | */ 12 | public class RecoveringErrorHandler extends DefaultErrorHandler { 13 | 14 | private static final Logger LOG = LoggerFactory.getLogger(RecoveringErrorHandler.class); 15 | 16 | public RecoveringErrorHandler() { 17 | super((record, exception) -> { 18 | Throwable cause = exception; 19 | if (exception instanceof ListenerExecutionFailedException) { 20 | cause = ((ListenerExecutionFailedException)exception).getRootCause(); 21 | } 22 | 23 | if (cause instanceof NullPointerException && record.value() == null) { 24 | // We know how to handle this 25 | LOG.error("Discarding null message at {}-{} offset {}", record.topic(), record.partition(), record.offset()); 26 | return; 27 | } 28 | 29 | throw new RuntimeException("Unable to recover from exception, retry the rest of the batch.", cause); 30 | }, new FixedBackOff(2000L, 2)); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /clients-spring/src/main/java/no/nav/kafka/sandbox/measurements/errorhandlers/RetryingErrorHandler.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.measurements.errorhandlers; 2 | 3 | import no.nav.kafka.sandbox.data.EventStore; 4 | import no.nav.kafka.sandbox.messages.Measurements; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.kafka.listener.DefaultErrorHandler; 8 | import org.springframework.kafka.listener.ListenerExecutionFailedException; 9 | import org.springframework.util.backoff.FixedBackOff; 10 | 11 | import java.io.IOException; 12 | 13 | /** 14 | * Error handler with access to event store, tries to recover records by writing to store, under certain 15 | * conditions. 16 | */ 17 | public class RetryingErrorHandler extends DefaultErrorHandler { 18 | 19 | private static final Logger LOG = LoggerFactory.getLogger(RetryingErrorHandler.class); 20 | 21 | public RetryingErrorHandler(EventStore store) { 22 | super((record, exception) -> { 23 | Throwable cause = exception; 24 | if (exception instanceof ListenerExecutionFailedException) { 25 | cause = exception.getCause(); 26 | } 27 | 28 | if (cause instanceof IOException) { 29 | // Something has gone wrong writing to event store, and here we assume it is a transient error condition and would 30 | // like the whole batch to be re-processed. 31 | throw new RuntimeException("Event store batch failure, retrying the whole batch", cause); 32 | } 33 | 34 | if (cause instanceof NullPointerException) { // Or any expected business level type which can be sensibly handled 35 | // Some message in batch failed because of null value 36 | if (record.value() == null) { 37 | LOG.error("Record at {}-{} offset {} has null value, skipping it", record.topic(), record.partition(), record.offset()); 38 | return; 39 | } 40 | 41 | if (!(record.value() instanceof Measurements.SensorEvent)) { 42 | LOG.error("Record at {}-{} offset {} has invalid message type, skipping it", record.topic(), record.partition(), record.offset()); 43 | return; 44 | } 45 | 46 | // This message however was not null, so we try to ensure it gets written into the event store. 47 | Measurements.SensorEvent recoveredEvent = (Measurements.SensorEvent) record.value(); 48 | if (store.storeEvent(recoveredEvent)) { // Any exception thrown from store here will cause the whole batch to be re-processed. 49 | LOG.info("Recovered and stored event at {}-{} offset {}", record.topic(), record.partition(), record.offset()); 50 | } 51 | return; 52 | } 53 | 54 | // We have no known way to handle this, so we let the whole batch be re-processed. 55 | // Depending on business requirements (e.g. if not at-least-once semantics), then another strategy might 56 | // be to skip the whole batch, let Spring commit offsets and continue with the next instead. 57 | throw new RuntimeException("Unrecoverable batch error", cause); 58 | }, new FixedBackOff(2000L, 2)); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /clients-spring/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: Kafka sandbox boot-app 4 | # See class org.springframework.boot.autoconfigure.kafka.KafkaProperties: 5 | kafka: 6 | bootstrap-servers: localhost:9092 7 | consumer: 8 | client-id: boot-app 9 | group-id: ${GROUPID:boot-app} 10 | properties: 11 | spring.json.trusted.packages: no.nav.kafka.sandbox.messages 12 | value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer 13 | auto-offset-reset: earliest 14 | 15 | logging: 16 | exception-conversion-word: "%wEx{short}" # shorter log traces are nice when experimenting with error handling 17 | level: 18 | org.apache.kafka: error 19 | 20 | consolemessages: 21 | event-store: 22 | max-size: 200 23 | consumer: 24 | topic: messages 25 | 26 | measurements: 27 | event-store: 28 | # max size, older events will be discarded, unless 'fail-on-max-size' is true 29 | max-size: 200 30 | # set to true to fail instead of discarding old elements when event store is full (test Kafka error handling for a persistent error) 31 | fail-on-max-size: false 32 | # set how often store writes should fail (to test Kafka consumer error handling for errors occuring sometimes) 33 | # Can be in two different forms: 34 | # 1. floating point number between 0.0 and 1.0, where failure will occur randomly at approximately this ratio. 35 | # 2. Two numbers x/y, where x denotes number of times to fail and y denotes total number of times for a cycle, before the 36 | # failure pattern repeats. E.g "9/10" will cause 9 consequtive failures and one success, before it repeats. 37 | failure-rate: 0.0 38 | 39 | consumer: 40 | topic: measurements 41 | 42 | # increase this to slow down batch consumer, which will result in getting larger batches for each call (value in milliseconds) 43 | slowdown: 0 44 | 45 | # Select error handler: 46 | # 'spring-default': just uses the Spring default for batch error handling (does not explicitly set an error handler). 47 | # 'log-and-ignore': logs, but ignores all errors from consumer, implemented in Spring error handler CommonLoggingErrorHandler. 48 | # 'infinite-retry': tries failed batches an infinite number of times, with a backoff/delay between each attempt. Spring DefaultErrorHandler with a BackOff. 49 | # 'retry-with-backoff': Spring DefaultErrorHandler with 2 retry attempts 50 | # 'retry-with-backoff-recovery': no.nav.k.s.m.e.RetryingErrorHandler with custom ConsumerRecordRecoverer set. 51 | # 'recovering': no.nav.k.s.m.e.RecoveringErrorHandler 52 | # 'stop-container': Spring CommonContainerStoppingErrorHandler 53 | error-handler: spring-default 54 | 55 | # Select whether consumer should throw BatchListenerFailedException w/cause when an internal processing failure occurs, or 56 | # just directly throw any exception. Setting to true will allow Spring to detect where a failure occured in a batch of multiple 57 | # records. 58 | useBatchListenerFailedException: false 59 | 60 | # Select whether deserialization exceptions of values should be handled: 61 | handle-deserialization-error: true 62 | -------------------------------------------------------------------------------- /clients-spring/src/main/resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spring Kafka consumer application 5 | 6 | 11 | 12 | 13 | 14 |

Spring Kafka consumer application

15 |

Measurements

16 |

Messages

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /clients-spring/src/main/resources/public/measurements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Measurements 5 | 6 | 28 | 29 | 30 | 31 |

Measurements

32 |

Ordered from newest to oldest, 0 events

33 | 34 | 35 | 36 | 37 | 38 |
device-idtimestamptypeunitvalue
39 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /clients-spring/src/main/resources/public/messages.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Console messages 5 | 6 | 13 | 14 | 15 | 16 |

Console messages

17 |

Ordered from newest to oldest.

18 |
19 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /clients-spring/src/test/java/no/nav/kafka/sandbox/data/EventStoreWithFailureRateTest.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.data; 2 | 3 | import no.nav.kafka.sandbox.data.EventStoreWithFailureRate.FixedFailSuccessCountPattern; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static org.junit.jupiter.api.Assertions.assertFalse; 7 | import static org.junit.jupiter.api.Assertions.assertTrue; 8 | 9 | public class EventStoreWithFailureRateTest { 10 | 11 | @Test 12 | public void testFixedFailSuccessCountStrategy() { 13 | FixedFailSuccessCountPattern fixedFailSuccessCountPattern = 14 | FixedFailSuccessCountPattern.fromSpec("2 / 3"); 15 | 16 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 17 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 18 | assertFalse(fixedFailSuccessCountPattern.failureFor("any")); 19 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 20 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 21 | assertFalse(fixedFailSuccessCountPattern.failureFor("any")); 22 | 23 | fixedFailSuccessCountPattern = FixedFailSuccessCountPattern.fromSpec("1/1"); 24 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 25 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 26 | } 27 | 28 | @Test 29 | public void testFixedFailSuccessCountStrategy_5_10() { 30 | FixedFailSuccessCountPattern fixedFailSuccessCountPattern = 31 | FixedFailSuccessCountPattern.fromSpec("5/10"); 32 | for (int i=0; i<5; i++) { 33 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 34 | } 35 | for (int i=0; i<5; i++) { 36 | assertFalse(fixedFailSuccessCountPattern.failureFor("any")); 37 | } 38 | for (int i=0; i<5; i++) { 39 | assertTrue(fixedFailSuccessCountPattern.failureFor("any")); 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /clients/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | no.nav.kafka 9 | kafka-sandbox 10 | 1.0-SNAPSHOT 11 | 12 | 13 | clients 14 | 15 | 16 | 17 | no.nav.kafka 18 | messages 19 | ${project.version} 20 | 21 | 22 | 23 | org.apache.kafka 24 | kafka-clients 25 | 26 | 27 | 28 | org.slf4j 29 | slf4j-api 30 | 31 | 32 | org.slf4j 33 | slf4j-simple 34 | runtime 35 | 36 | 37 | 38 | com.fasterxml.jackson.core 39 | jackson-core 40 | 41 | 42 | com.fasterxml.jackson.core 43 | jackson-databind 44 | 45 | 46 | com.fasterxml.jackson.core 47 | jackson-annotations 48 | 49 | 50 | com.fasterxml.jackson.datatype 51 | jackson-datatype-jdk8 52 | 53 | 54 | com.fasterxml.jackson.datatype 55 | jackson-datatype-jsr310 56 | 57 | 58 | 59 | org.junit.jupiter 60 | junit-jupiter 61 | test 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | org.apache.maven.plugins 71 | maven-dependency-plugin 72 | 73 | 74 | copy-dependencies 75 | prepare-package 76 | 77 | copy-dependencies 78 | 79 | 80 | runtime 81 | 82 | ${project.build.directory}/libs 83 | 84 | 85 | 86 | 87 | 88 | 89 | org.apache.maven.plugins 90 | maven-jar-plugin 91 | 92 | 93 | 94 | true 95 | libs/ 96 | no.nav.kafka.sandbox.Bootstrap 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /clients/src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | module clients { 2 | requires no.nav.kafka.sandbox.messages; 3 | requires org.slf4j; 4 | requires kafka.clients; 5 | requires com.fasterxml.jackson.annotation; 6 | requires com.fasterxml.jackson.datatype.jdk8; 7 | requires com.fasterxml.jackson.datatype.jsr310; 8 | requires com.fasterxml.jackson.databind; 9 | } 10 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/Bootstrap.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.SerializationFeature; 5 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 7 | import no.nav.kafka.sandbox.admin.TopicAdmin; 8 | import no.nav.kafka.sandbox.consumer.JsonMessageConsumer; 9 | import no.nav.kafka.sandbox.messages.ConsoleMessages; 10 | import no.nav.kafka.sandbox.messages.Measurements; 11 | import no.nav.kafka.sandbox.messages.SequenceValidation; 12 | import no.nav.kafka.sandbox.producer.JsonMessageProducer; 13 | import no.nav.kafka.sandbox.producer.NullMessageProducer; 14 | import no.nav.kafka.sandbox.producer.StringMessageProducer; 15 | import org.slf4j.Logger; 16 | import org.slf4j.LoggerFactory; 17 | 18 | import java.io.File; 19 | import java.util.*; 20 | import java.util.concurrent.TimeUnit; 21 | import java.util.function.Consumer; 22 | import java.util.function.Function; 23 | import java.util.function.Supplier; 24 | 25 | /** 26 | * A simple Kafka Java producer/consumer demo app with minimized set of dependencies. 27 | * 28 | *

Purpose:

29 | *
    30 | *
  1. Get quickly up and running with Kafka using standard Java Kafka client.
  2. 31 | *
  3. Experiment with the settings to learn and understand behaviour.
  4. 32 | *
  5. Experiment with the console clients to learn about communication patterns possible with Kafka, and 33 | * how topic partitions and consumer groups work in practice.
  6. 34 | *
  7. Easily modify and re-run code in the experimentation process.
  8. 35 | *
  9. Create unit/integration tests that use Kafka.
  10. 36 | *
37 | */ 38 | public class Bootstrap { 39 | 40 | final static String DEFAULT_BROKER = "localhost:9092"; 41 | final static String MEASUREMENTS_TOPIC = "measurements"; 42 | final static String MESSAGES_TOPIC = "messages"; 43 | final static String SEQUENCE_TOPIC = "sequence"; 44 | final static String CONSUMER_GROUP_DEFAULT = "console"; 45 | 46 | private static final Logger LOG = LoggerFactory.getLogger(Bootstrap.class); 47 | 48 | public static void main(String...a) { 49 | final LinkedList args = new LinkedList(Arrays.asList(a)); 50 | 51 | if (args.isEmpty() || args.get(0).isBlank() || args.contains("-h") || args.contains("--help")) { 52 | System.err.println("Use: 'producer [TOPIC [P]]' or 'consumer [TOPIC [GROUP]]'"); 53 | System.err.println("Use: 'produce N [TOPIC [P]]' to produce exactly N measurements to topic with no delay"); 54 | System.err.println("Use: 'console-message-producer [TOPIC [P]]' or 'console-message-consumer [TOPIC [GROUP]]'"); 55 | System.err.println("Use: 'sequence-producer [TOPIC [P]]' or 'sequence-consumer [TOPIC [GROUP]]'"); 56 | System.err.println("Use: 'null-producer [TOPIC [P]]' to produce a single message with null value"); 57 | System.err.println("Use: 'string-producer STRING [TOPIC [P]]' to produce a single UTF-8 encoded string message"); 58 | System.err.println("Use: 'newtopic TOPIC [N]' to create a topic with N partitions (default 1)."); 59 | System.err.println("Use: 'deltopic TOPIC' to delete a topic."); 60 | System.err.println("Use: 'showtopics' to list topics/partitions available."); 61 | System.err.println("Default topic is chosen according to consumer/producer type."); 62 | System.err.println("Default consumer group is '"+ CONSUMER_GROUP_DEFAULT + "'"); 63 | System.err.println("Kafka broker is " + DEFAULT_BROKER); 64 | System.exit(1); 65 | } 66 | 67 | try { 68 | switch (args.remove()) { 69 | case "newtopic": 70 | newTopic(args); 71 | break; 72 | 73 | case "deltopic": 74 | deleteTopic(args); 75 | break; 76 | 77 | case "showtopics": 78 | showTopics(args); 79 | break; 80 | 81 | case "producer": 82 | measurementProducer(args); 83 | break; 84 | 85 | case "produce": 86 | measurementProduceFinite(args); 87 | break; 88 | 89 | case "consumer": 90 | measurementConsumer(args); 91 | break; 92 | 93 | case "sequence-producer": 94 | sequenceProducer(args); 95 | break; 96 | 97 | case "null-producer": 98 | nullProducer(args); 99 | break; 100 | 101 | case "string-producer": 102 | stringProducer(args); 103 | break; 104 | 105 | case "sequence-consumer": 106 | sequenceValidatorConsumer(args); 107 | break; 108 | 109 | case "console-message-producer": 110 | consoleMessageProducer(args); 111 | break; 112 | 113 | case "console-message-consumer": 114 | consoleMessageConsumer(args); 115 | break; 116 | 117 | default: 118 | System.err.println("Invalid mode"); 119 | System.exit(1); 120 | } 121 | } catch (IllegalArgumentException | NoSuchElementException e) { 122 | System.err.println("Bad syntax"); 123 | System.exit(1); 124 | } 125 | } 126 | 127 | private static void showTopics(Queue args) { 128 | try (TopicAdmin ta = new TopicAdmin(DEFAULT_BROKER)) { 129 | LOG.info("Topic-partitions available at broker {}:", DEFAULT_BROKER); 130 | ta.listTopics().stream().sorted().forEach(t -> System.out.println(t)); 131 | } catch (Exception e) { 132 | System.err.println("Failed: "+ e.getMessage()); 133 | } 134 | } 135 | 136 | private static void newTopic(Queue args) { 137 | try (TopicAdmin ta = new TopicAdmin(DEFAULT_BROKER)) { 138 | String topic = args.remove(); 139 | int partitions = args.isEmpty() ? 1 : Integer.parseInt(args.remove()); 140 | ta.create(topic, partitions); 141 | LOG.info("New topic '{}' created with {} partitions.", topic, partitions); 142 | } catch (Exception e) { 143 | System.err.println("Failed: "+ e.getMessage()); 144 | } 145 | } 146 | 147 | private static void deleteTopic(Queue args) { 148 | try (TopicAdmin ta = new TopicAdmin(DEFAULT_BROKER)) { 149 | String topic = args.remove(); 150 | ta.delete(topic); 151 | LOG.info("Delete topic '{}'", topic); 152 | } catch (Exception e) { 153 | System.err.println("Failed: "+ e.getMessage()); 154 | } 155 | } 156 | 157 | private static void measurementProducer(Queue args) { 158 | String topic = args.isEmpty() ? MEASUREMENTS_TOPIC : args.remove(); 159 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 160 | jsonProducer(topic, partition, Measurements.delayedInfiniteEventSupplier(), m -> m.getDeviceId()); 161 | } 162 | 163 | private static void measurementProduceFinite(Queue args) { 164 | if (args.isEmpty()) { 165 | throw new IllegalArgumentException("Missing number of messages to produce"); 166 | } 167 | Integer n = Integer.parseInt(args.remove()); 168 | String topic = args.isEmpty() ? MEASUREMENTS_TOPIC : args.remove(); 169 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 170 | jsonProducer(topic, partition, Measurements.eventSupplier(n), m -> m.getDeviceId()); 171 | } 172 | 173 | private static void measurementConsumer(Queue args) { 174 | String topic = args.isEmpty() ? MEASUREMENTS_TOPIC : args.remove(); 175 | String group = args.isEmpty() ? CONSUMER_GROUP_DEFAULT : args.remove(); 176 | jsonConsumer(topic, group, Measurements.SensorEvent.class, Measurements::sensorEventToConsole); 177 | } 178 | 179 | private static void consoleMessageProducer(Queue args) { 180 | String topic = args.isEmpty() ? MESSAGES_TOPIC : args.remove(); 181 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 182 | jsonProducer(topic, partition, ConsoleMessages.consoleMessageSupplier(), m -> m.senderId); 183 | } 184 | 185 | private static void consoleMessageConsumer(Queue args) { 186 | String topic = args.isEmpty() ? MESSAGES_TOPIC : args.remove(); 187 | String group = args.isEmpty() ? CONSUMER_GROUP_DEFAULT : args.remove(); 188 | jsonConsumer(topic, group, ConsoleMessages.Message.class, ConsoleMessages.consoleMessageConsumer()); 189 | } 190 | 191 | private static void sequenceProducer(Queue args) { 192 | String topic = args.isEmpty() ? SEQUENCE_TOPIC : args.remove(); 193 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 194 | jsonProducer(topic, partition, SequenceValidation.sequenceSupplier( 195 | new File("sequence-producer.state"), 1, TimeUnit.SECONDS), m -> null); 196 | } 197 | 198 | private static void sequenceValidatorConsumer(Queue args) { 199 | String topic = args.isEmpty() ? SEQUENCE_TOPIC : args.remove(); 200 | String group = args.isEmpty() ? CONSUMER_GROUP_DEFAULT : args.remove(); 201 | jsonConsumer(topic, group, Long.class, SequenceValidation.sequenceValidatorConsolePrinter()); 202 | } 203 | 204 | private static void nullProducer(Queue args) { 205 | String topic = args.isEmpty() ? MEASUREMENTS_TOPIC : args.remove(); 206 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 207 | new NullMessageProducer(topic, partition, KafkaConfig.kafkaProducerProps(), m -> null) 208 | .produce(); 209 | } 210 | 211 | private static void stringProducer(Queue args) { 212 | String message = Objects.requireNonNull(args.remove(), "Message cannot be null"); 213 | String topic = args.isEmpty() ? MEASUREMENTS_TOPIC : args.remove(); 214 | Integer partition = args.isEmpty() ? null : Integer.parseInt(args.remove()); 215 | new StringMessageProducer(topic, partition, KafkaConfig.kafkaProducerProps(), message, 216 | m -> String.valueOf(Math.abs(m.hashCode()))) 217 | .produce(); 218 | } 219 | 220 | private static void jsonProducer(String topic, Integer partition, Supplier messageSupplier, Function keyFunction) { 221 | LOG.info("New producer with PID " + obtainPid()); 222 | JsonMessageProducer producer = new JsonMessageProducer<>(topic, partition, KafkaConfig.kafkaProducerProps(), objectMapper(), 223 | messageSupplier, keyFunction, true); 224 | 225 | Thread main = Thread.currentThread(); 226 | Runtime.getRuntime().addShutdownHook(new Thread(() -> { 227 | main.interrupt(); 228 | try { main.join(2000); } catch (InterruptedException ie){ } 229 | })); 230 | 231 | producer.produceLoop(); 232 | } 233 | 234 | private static void jsonConsumer(String topic, String group, Class messageType, Consumer messageHandler) { 235 | LOG.info("New consumer with PID " + obtainPid()); 236 | JsonMessageConsumer consumer = 237 | new JsonMessageConsumer(topic, messageType, KafkaConfig.kafkaConsumerProps(group), 238 | objectMapper(), messageHandler); 239 | Thread main = Thread.currentThread(); 240 | Runtime.getRuntime().addShutdownHook(new Thread(() -> { 241 | // Cannot directly close KafkaConsumer in shutdown hook, 242 | // since this code runs in another thread, and KafkaConsumer complains loudly if accessed by multiple threads 243 | main.interrupt(); 244 | try { main.join(2000); } catch (InterruptedException ie){ } 245 | })); 246 | 247 | consumer.consumeLoop(); 248 | } 249 | 250 | static long obtainPid() { 251 | return ProcessHandle.current().pid(); 252 | } 253 | 254 | static ObjectMapper objectMapper() { 255 | return new ObjectMapper() 256 | .registerModule(new Jdk8Module()) 257 | .registerModule(new JavaTimeModule()) 258 | .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 259 | } 260 | 261 | } 262 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/KafkaConfig.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import org.apache.kafka.clients.consumer.ConsumerConfig; 4 | import org.apache.kafka.clients.producer.ProducerConfig; 5 | import org.apache.kafka.common.serialization.StringDeserializer; 6 | import org.apache.kafka.common.serialization.StringSerializer; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | /** 12 | * Common Kafka config for producer/consumers. 13 | */ 14 | class KafkaConfig { 15 | 16 | /** 17 | * See Producer configs 18 | */ 19 | static Map kafkaProducerProps() { 20 | var props = new HashMap(); 21 | props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, Bootstrap.DEFAULT_BROKER); 22 | props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); 23 | props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); 24 | props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE); // default is "infinite" number of retries, within the constraints of other related settings 25 | props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 10000); // 10 seconds delivery timeout, default is 120 seconds 26 | props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, 10000); // Request timeout, default is 30 seconds 27 | props.put(ProducerConfig.LINGER_MS_CONFIG, 0); // At default value of 0, affects batching of messages 28 | props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // at default value of 16384 bytes 29 | props.put(ProducerConfig.ACKS_CONFIG, "1"); // Require ack from leader only, at default value 30 | return props; 31 | 32 | // More complex setup with authentication (currently beyond scope of this demo): 33 | // props.put("ssl.endpoint.identification.algorithm", "https"); 34 | // props.put("sasl.mechanism", "PLAIN"); 35 | // props.put("request.timeout.ms", 5000); 36 | // props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "pkc-43mml.europe-west2.gcp.confluent.cloud:9092"); 37 | // props.put("sasl.jaas.config","org.apache.kafka.common.security.plain.PlainLoginModule required username=\"\" password=\"";"); 38 | // props.put("security.protocol","SASL_SSL"); 39 | // props.put("basic.auth.credentials.source","USER_INFO"); 40 | // props.put("schema.registry.basic.auth.user.info", "api-key:api-secret"); 41 | // props.put("schema.registry.url", ""); 42 | // props.put(ProducerConfig.CLIENT_ID_CONFIG, "no.nav.kafka.sandbox"); 43 | } 44 | 45 | /** 46 | * See Consumer configs 47 | */ 48 | static Map kafkaConsumerProps(String groupId) { 49 | var props = new HashMap(); 50 | props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Bootstrap.DEFAULT_BROKER); 51 | props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); 52 | props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); 53 | props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); 54 | props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); 55 | props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); // In case no offset is stored for consumer group, start at beginning 56 | props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); // Max time allowed for message handling between calls to poll, default value 57 | return props; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/admin/TopicAdmin.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.admin; 2 | 3 | import org.apache.kafka.clients.admin.AdminClient; 4 | import org.apache.kafka.clients.admin.AdminClientConfig; 5 | import org.apache.kafka.clients.admin.NewTopic; 6 | 7 | import java.util.Collections; 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.stream.Collectors; 11 | 12 | public class TopicAdmin implements AutoCloseable { 13 | 14 | private final AdminClient adminClient; 15 | 16 | public TopicAdmin(String broker) { 17 | adminClient = AdminClient.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, broker)); 18 | } 19 | 20 | /** 21 | * Creates topic with desired partition count and waits until it is ready before returning. 22 | * @param numPartitions 23 | */ 24 | public void create(String topic, int numPartitions) throws Exception { 25 | adminClient.createTopics(Collections.singleton(new NewTopic(topic, numPartitions, (short)1))).all().get(); 26 | } 27 | 28 | public void delete(String topic) throws Exception { 29 | adminClient.deleteTopics(Collections.singleton(topic)).all().get(); 30 | } 31 | 32 | public List listTopics() throws Exception { 33 | return adminClient.describeTopics(adminClient.listTopics().names().get()).all().get() 34 | .values().stream().flatMap( 35 | td -> td.partitions().stream().map(p -> td.name() + "-" + p.partition())) 36 | .collect(Collectors.toList()); 37 | } 38 | 39 | @Override 40 | public void close() { 41 | this.adminClient.close(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/consumer/JsonMessageConsumer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.consumer; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.apache.kafka.clients.consumer.*; 5 | import org.apache.kafka.common.KafkaException; 6 | import org.apache.kafka.common.TopicPartition; 7 | import org.apache.kafka.common.errors.InterruptException; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | import java.time.Duration; 12 | import java.util.Collection; 13 | import java.util.Collections; 14 | import java.util.Map; 15 | import java.util.concurrent.atomic.AtomicInteger; 16 | import java.util.function.Consumer; 17 | 18 | /** 19 | * Expects JSON-formatted messages, deserializes using specific type and passes message objects to provided handler 20 | * as they come in from Kafka. 21 | * 22 | *

Message handler does not need to be thread safe unless shared with other Kafka consumer clients.

23 | */ 24 | public class JsonMessageConsumer { 25 | 26 | private final ObjectMapper mapper; 27 | private final String topic; 28 | private Map kafkaConfig; 29 | private final Logger log = LoggerFactory.getLogger(getClass()); 30 | private final Class messageType; 31 | private final Consumer messageHandler; 32 | private final AtomicInteger successConsumeCount = new AtomicInteger(0); 33 | 34 | public JsonMessageConsumer(String topic, Class messageType, Map kafkaConfig, 35 | ObjectMapper mapper, Consumer messageHandler) { 36 | this.mapper = mapper; 37 | this.messageType = messageType; 38 | this.topic = topic; 39 | this.kafkaConfig = kafkaConfig; 40 | this.messageHandler = messageHandler; 41 | } 42 | 43 | private KafkaConsumer initKafkaConsumer(String topic, Map kafkaConfig) { 44 | KafkaConsumer consumer = new KafkaConsumer<>(kafkaConfig); 45 | log.debug("Subscribe to topic '{}' as part of consumer group '{}'", topic, kafkaConfig.get(ConsumerConfig.GROUP_ID_CONFIG)); 46 | 47 | // Subscribe, which uses auto-partition assignment (Kafka consumer-group balancing) 48 | consumer.subscribe(Collections.singleton(topic), new ConsumerRebalanceListener() { 49 | @Override 50 | public void onPartitionsRevoked(Collection partitions) { 51 | partitions.forEach(tp -> { 52 | log.info("Rebalance: no longer assigned to topic {}, partition {}", tp.topic(), tp.partition()); 53 | }); 54 | } 55 | @Override 56 | public void onPartitionsAssigned(Collection partitions) { 57 | partitions.forEach(tp -> { 58 | log.info("Rebalance: assigned to topic {}, partition {}", tp.topic(), tp.partition()); 59 | }); 60 | } 61 | }); 62 | 63 | // Another way would be to explicitly assign a partition to this consumer, by topic and number 64 | //consumer.assign(Collections.singleton(new TopicPartition(topic, 0))); 65 | //log.info("consumer assigned partition '{}'", waitForAssignment(consumer)); 66 | return consumer; 67 | } 68 | 69 | /** 70 | * Initialize Kafka consumer, enter consume loop, run until interrupted, then close Kafka consumer. 71 | */ 72 | public void consumeLoop() { 73 | KafkaConsumer kafkaConsumer = initKafkaConsumer(topic, kafkaConfig); 74 | log.info("Start consumer loop"); 75 | while (!Thread.interrupted()) { 76 | log.info("Poll .."); 77 | try { 78 | ConsumerRecords records = kafkaConsumer.poll(Duration.ofSeconds(10)); 79 | if (records.count() > 0) { 80 | log.info("Total records count fetched: {}, partitions: {}", records.count(), records.partitions()); 81 | 82 | records.records(topic).forEach(this::handleRecord); 83 | 84 | // Could avoid commit if handler failed for one or more records here, then seek to start of 85 | // Not needed if using auto-commit strategy, see Kafka consumer config. 86 | kafkaConsumer.commitSync(); 87 | records.partitions().forEach(tp -> { 88 | log.info("Committed offset {} for {}", 89 | kafkaConsumer.committed(Collections.singleton(tp)).get(tp).offset(), tp); 90 | }); 91 | } 92 | } catch (InterruptException ie) { // Note: Kafka-specific interrupt exception 93 | // Expected on shutdown from console 94 | } catch (KafkaException ke) { 95 | log.error("KafkaException occured in consumeLoop", ke); 96 | } 97 | } 98 | log.info("Closing KafkaConsumer .."); 99 | kafkaConsumer.close(); 100 | log.info("Successfully consumed {} messages.", successConsumeCount.get()); 101 | } 102 | 103 | private void handleRecord(ConsumerRecord record) { 104 | try { 105 | T value = deserialize(record.value(), this.messageType); 106 | this.messageHandler.accept(value); 107 | successConsumeCount.incrementAndGet(); 108 | } catch (Exception e) { 109 | log.error("Handle record with offset {}, failed to in message handler or deserialization: {}" , record.offset(), e.getMessage()); 110 | } 111 | } 112 | 113 | private T deserialize(String json, Class messageType) throws Exception { 114 | return mapper.readValue(json, messageType); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/producer/JsonMessageProducer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.producer; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.apache.kafka.clients.producer.KafkaProducer; 5 | import org.apache.kafka.clients.producer.ProducerRecord; 6 | import org.apache.kafka.clients.producer.RecordMetadata; 7 | import org.apache.kafka.common.errors.InterruptException; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | import java.util.Map; 12 | import java.util.NoSuchElementException; 13 | import java.util.concurrent.ExecutionException; 14 | import java.util.concurrent.Future; 15 | import java.util.concurrent.atomic.AtomicInteger; 16 | import java.util.function.Consumer; 17 | import java.util.function.Function; 18 | import java.util.function.Supplier; 19 | 20 | public class JsonMessageProducer { 21 | 22 | private final ObjectMapper mapper; 23 | private final Map kafkaSettings; 24 | private final String topic; 25 | private static final Logger log = LoggerFactory.getLogger(JsonMessageProducer.class); 26 | private final Supplier messageSupplier; 27 | private final Function keyFunction; 28 | private final Integer partition; 29 | private final boolean nonBlockingSend; 30 | 31 | /** 32 | * 33 | * @param topic Kakfa topic to send to 34 | * @param partition desired partition, or {@code null} for selection based on message key 35 | * @param kafkaSettings 36 | * @param mapper 37 | * @param messageSupplier supplier of messages 38 | * @param keyFunction function to derive a key from a message instance. The function may return {@code null} if no 39 | * key is desired. 40 | * @param nonBlockingSend whether to do non-blocking send or wait for each ack 41 | */ 42 | public JsonMessageProducer(String topic, Integer partition, Map kafkaSettings, ObjectMapper mapper, 43 | Supplier messageSupplier, Function keyFunction, 44 | boolean nonBlockingSend) { 45 | this.mapper = mapper; 46 | this.kafkaSettings = kafkaSettings; 47 | this.topic = topic; 48 | this.partition = partition; 49 | this.messageSupplier = messageSupplier; 50 | this.keyFunction = keyFunction; 51 | this.nonBlockingSend = nonBlockingSend; 52 | } 53 | 54 | /** 55 | * Send as fast as the supplier can generate messages, until interrupted, then close producer 56 | */ 57 | public void produceLoop() { 58 | log.info("Start producer loop"); 59 | final KafkaProducer kafkaProducer = new KafkaProducer<>(kafkaSettings); 60 | final SendStrategy sendStrategy = nonBlockingSend ? nonBlocking(kafkaProducer) : blocking(kafkaProducer); 61 | 62 | final AtomicInteger sendCount = new AtomicInteger(0); 63 | while (!Thread.interrupted()) { 64 | try { 65 | T message = messageSupplier.get(); 66 | String key = keyFunction.apply(message); 67 | sendStrategy.send(key, message, recordMetadata -> sendCount.incrementAndGet()); 68 | } catch (InterruptException kafkaInterrupt) { 69 | // Expected on shutdown from console 70 | } catch (NoSuchElementException depleted) { 71 | // Supplier is depleted 72 | break; 73 | } catch (Exception ex) { 74 | log.error("Unexpected error when sending to Kafka", ex); 75 | } 76 | } 77 | kafkaProducer.close(); 78 | log.info("Sent in total {} messages", sendCount.get()); 79 | } 80 | 81 | @FunctionalInterface 82 | interface SendStrategy { 83 | void send(String key, T message, Consumer successCallback) throws Exception; 84 | } 85 | 86 | /** 87 | * Non-blocking send, generally does not block, prints status in callback invoked by Kafka producer 88 | */ 89 | private SendStrategy nonBlocking(KafkaProducer kafkaProducer) { 90 | return (String key, T message, Consumer successCallback) -> { 91 | final String json = mapper.writeValueAsString(message); 92 | log.debug("Send non-blocking .."); 93 | kafkaProducer.send(new ProducerRecord<>(topic, partition, key, json), (metadata, ex) -> { 94 | if (ex != null) { 95 | log.error("Failed to send message to Kafka", ex); 96 | } else { 97 | log.debug("Async message ack, offset: {}, timestamp: {}, topic-partition: {}-{}", 98 | metadata.offset(), metadata.timestamp(), metadata.topic(), metadata.partition()); 99 | successCallback.accept(metadata); 100 | } 101 | }); 102 | }; 103 | } 104 | 105 | /** 106 | * Blocking send strategy, waits for result of sending, then prints status and returns 107 | */ 108 | private SendStrategy blocking(KafkaProducer kafkaProducer) { 109 | return (String key, T message, Consumer successCallback) -> { 110 | String json = mapper.writeValueAsString(message); 111 | log.debug("Send blocking .."); 112 | Future send = kafkaProducer.send(new ProducerRecord<>(topic, partition, key, json)); 113 | try { 114 | RecordMetadata metadata = send.get(); 115 | log.debug("Message ack, offset: {}, timestamp: {}, topic-partition: {}-{}", 116 | metadata.offset(), metadata.timestamp(), metadata.topic(), metadata.partition()); 117 | successCallback.accept(metadata); 118 | } catch (ExecutionException exception) { 119 | log.error("Failed to send message to Kafka", exception.getCause()); 120 | } 121 | }; 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/producer/NullMessageProducer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.producer; 2 | 3 | import org.apache.kafka.clients.producer.KafkaProducer; 4 | import org.apache.kafka.clients.producer.ProducerRecord; 5 | import org.apache.kafka.clients.producer.RecordMetadata; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | import java.util.Map; 10 | import java.util.Objects; 11 | import java.util.concurrent.ExecutionException; 12 | import java.util.function.Function; 13 | 14 | public class NullMessageProducer { 15 | 16 | private final Map kafkaSettings; 17 | private final String topic; 18 | private final Integer partition; 19 | private final Function keyFunction; 20 | 21 | private static final Logger log = LoggerFactory.getLogger(NullMessageProducer.class); 22 | 23 | public NullMessageProducer(String topic, Integer partition, Map kafkaSettings, Function keyFunction) { 24 | this.topic = Objects.requireNonNull(topic); 25 | this.partition = partition; 26 | this.kafkaSettings = Objects.requireNonNull(kafkaSettings); 27 | this.keyFunction = Objects.requireNonNull(keyFunction); 28 | } 29 | 30 | public void produce() { 31 | final KafkaProducer kafkaProducer = new KafkaProducer<>(kafkaSettings); 32 | final String key = keyFunction.apply(null); 33 | 34 | try { 35 | RecordMetadata recordMetadata = kafkaProducer.send(new ProducerRecord<>(topic, partition, key, null)).get(); 36 | log.info("Sent a null message to {}-{} with key {}, record offset={}, timestamp={}", 37 | recordMetadata.topic(), recordMetadata.partition(), key, recordMetadata.offset(), recordMetadata.timestamp()); 38 | } catch (InterruptedException e) { 39 | } catch (ExecutionException e) { 40 | log.error("Error sending to Kafka", e); 41 | } 42 | 43 | kafkaProducer.close(); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /clients/src/main/java/no/nav/kafka/sandbox/producer/StringMessageProducer.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.producer; 2 | 3 | import org.apache.kafka.clients.producer.KafkaProducer; 4 | import org.apache.kafka.clients.producer.ProducerRecord; 5 | import org.apache.kafka.clients.producer.RecordMetadata; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | import java.util.Map; 10 | import java.util.Objects; 11 | import java.util.concurrent.ExecutionException; 12 | import java.util.function.Function; 13 | 14 | public class StringMessageProducer { 15 | 16 | private final Map kafkaSettings; 17 | private final String topic; 18 | private final Integer partition; 19 | private final Function keyFunction; 20 | private final String message; 21 | 22 | private static final Logger log = LoggerFactory.getLogger(StringMessageProducer.class); 23 | 24 | public StringMessageProducer(String topic, Integer partition, Map kafkaSettings, String message, Function keyFunction) { 25 | this.topic = Objects.requireNonNull(topic); 26 | this.partition = partition; 27 | this.kafkaSettings = Objects.requireNonNull(kafkaSettings); 28 | this.message = Objects.requireNonNull(message); 29 | this.keyFunction = Objects.requireNonNull(keyFunction); 30 | } 31 | 32 | public void produce() { 33 | final KafkaProducer kafkaProducer = new KafkaProducer<>(kafkaSettings); 34 | final String key = keyFunction.apply(message); 35 | 36 | try { 37 | RecordMetadata recordMetadata = kafkaProducer.send(new ProducerRecord<>(topic, partition, key, message)).get(); 38 | log.info("Sent '{}' to {}-{} with key {}, record offset={}, timestamp={}", message, 39 | recordMetadata.topic(), recordMetadata.partition(), key, recordMetadata.offset(), recordMetadata.timestamp()); 40 | } catch (InterruptedException e) { 41 | } catch (ExecutionException e) { 42 | log.error("Error sending to Kafka", e); 43 | } 44 | 45 | kafkaProducer.close(); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /clients/src/main/resources/simplelogger.properties: -------------------------------------------------------------------------------- 1 | org.slf4j.simpleLogger.logFile=System.out 2 | org.slf4j.simpleLogger.showShortLogName=true 3 | org.slf4j.simpleLogger.showDateTime=true 4 | 5 | org.slf4j.simpleLogger.defaultLogLevel=DEBUG 6 | org.slf4j.simpleLogger.log.org.apache.kafka=WARN 7 | -------------------------------------------------------------------------------- /clients/src/test/java/no/nav/kafka/sandbox/DockerComposeEnv.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | import javax.net.ServerSocketFactory; 7 | import java.io.*; 8 | import java.net.*; 9 | import java.nio.file.Path; 10 | import java.util.*; 11 | import java.util.concurrent.CompletableFuture; 12 | import java.util.concurrent.TimeUnit; 13 | import java.util.concurrent.atomic.AtomicLong; 14 | import java.util.function.Function; 15 | import java.util.function.Supplier; 16 | import java.util.regex.Matcher; 17 | import java.util.regex.Pattern; 18 | import java.util.stream.Collectors; 19 | import java.util.stream.Stream; 20 | 21 | import static java.lang.ProcessBuilder.Redirect.appendTo; 22 | 23 | /** 24 | * All-in-one support class for invoking docker compose and waiting for services to become ready, 25 | * typically to be used in tests. 26 | * 27 | * This is basically a simple version of TestContainers implemented in a single Java file, 28 | * where you must supply your own Docker Compose spec file and setup any desired ready-tests programatically. 29 | */ 30 | public final class DockerComposeEnv implements AutoCloseable { 31 | 32 | private static final Logger log = LoggerFactory.getLogger(DockerComposeEnv.class); 33 | 34 | /** 35 | * Builder which is used to setup the docker-compose invocation. 36 | * 37 | *

You can use multiple ready-tests per environment. The {@link Builder#up() up method} will wait until 38 | * all ready-tests complete before returning the instance of {@code DockerComposeEnv}. The ready-tests 39 | * are executed in parallel.

40 | */ 41 | public static class Builder { 42 | private final String config; 43 | private String logdir = null; 44 | private final Map env = new HashMap<>(); 45 | private final List> readyTests = new ArrayList<>(); 46 | private int lastAutoPort = 39999; 47 | private int dockerComposeTimeoutSeconds = 1800; 48 | 49 | private Builder(String config) { 50 | this.config = Objects.requireNonNull(config); 51 | } 52 | 53 | /** 54 | * Add low level ready-test that checks if a single TCP port can be connected to. 55 | * @param host 56 | * @param port 57 | * @return this builder 58 | */ 59 | public Builder readyWhenPortIsOpen(String host, int port) { 60 | this.readyTests.add(() -> { 61 | try (Socket s = new Socket(InetAddress.getByName(host), port)) { 62 | log.info("Port is ready: {}:{}", host, port); 63 | return true; 64 | } catch (IOException e) { 65 | return false; 66 | } 67 | }); 68 | return this; 69 | } 70 | 71 | /** 72 | * Same as {@link #readyWhenPortIsOpen(String, int)}, but uses a previously configured env variable 73 | * as the port, which can be a variable set with {@link #addAutoPortVariable(String)}. 74 | * @param host typically "localhost" 75 | * @param portVariable name of env variable 76 | * @throws NullPointerException if env variable is not previously configured 77 | * @throws NumberFormatException if env variable is not parseable as an integer 78 | * @return this builder 79 | */ 80 | public Builder readyWhenPortIsOpen(String host, String portVariable) { 81 | final int port = Integer.parseInt(env.get(portVariable)); 82 | return readyWhenPortIsOpen(host, port); 83 | } 84 | 85 | /** 86 | * Add ready when an http-GET to a URL yields status code 2xx. 87 | * @param url 88 | * @return this builder 89 | */ 90 | public Builder readyOnHttpGet2xx(String url) { 91 | if (!Objects.requireNonNull(url, "URL cannot be null").startsWith("http")) { 92 | throw new IllegalArgumentException("URL must start with 'http'"); 93 | } 94 | this.readyTests.add(() -> { 95 | HttpURLConnection httpConnection = null; 96 | try { 97 | httpConnection = (HttpURLConnection)new URL(url).openConnection(); 98 | httpConnection.setConnectTimeout(1000); 99 | httpConnection.setReadTimeout(2000); 100 | final int responseCode = httpConnection.getResponseCode(); 101 | if (responseCode >= 200 && responseCode < 300) { 102 | log.info("Http-service is ready: {}", url); 103 | return true; 104 | } else { 105 | return false; 106 | } 107 | } catch (IOException e) { 108 | return false; 109 | } finally { 110 | try { 111 | if (httpConnection != null) { 112 | httpConnection.getInputStream().close(); 113 | } 114 | } catch (IOException io) {} 115 | } 116 | }); 117 | return this; 118 | } 119 | 120 | /** 121 | * Add test which is ready when an http-GET to a URL yields status code 2xx. 122 | * @param urlTemplate the URL, where "{VALUE}" is replaced by the value of a an env-variable, which 123 | * must be previously set and can be an auto-port-variable. 124 | * @param envVariable variable to use 125 | * @return this builder 126 | */ 127 | public Builder readyOnHttpGet2xx(String urlTemplate, String envVariable) { 128 | return readyOnHttpGet2xx(urlTemplate.replaceAll(Pattern.quote("{VALUE}"), env.get(envVariable))); 129 | } 130 | 131 | /** 132 | * Add simple ready-test which simply returns {@code false} until the desired amount of time has elapsed since 133 | * the call to this method method. 134 | */ 135 | public Builder readyAfter(long duration, TimeUnit timeUnit) { 136 | final AtomicLong start = new AtomicLong(-1); 137 | final long millisToWait = timeUnit.toMillis(duration); 138 | this.readyTests.add(() -> { 139 | if (start.compareAndSet(-1, System.currentTimeMillis())) { 140 | return false; 141 | } 142 | if (System.currentTimeMillis() - start.get() > millisToWait) { 143 | log.info("Waited for at least {} milliseconds, signalling ready", millisToWait); 144 | return true; 145 | } else { 146 | return false; 147 | } 148 | }); 149 | return this; 150 | } 151 | 152 | /** 153 | * Add environment variable to export to docker-compose process. 154 | *

The value of this variable can later be retrieved with {@link #getEnvVariable(String)}

155 | * @param variable name, not null 156 | * @param value value 157 | * @return the builder 158 | */ 159 | public Builder addEnvVariable(String variable, Object value) { 160 | env.put(Objects.requireNonNull(variable), value != null ? value.toString() : "null"); 161 | return this; 162 | } 163 | 164 | /** 165 | * Get value of a previously set variable, which can be an auto-port-variable. 166 | * @param variable 167 | * @return 168 | */ 169 | public String getEnvVariable(String variable) { 170 | return env.get(variable); 171 | } 172 | 173 | /** 174 | * Expose an environment variable having a random (currently) free port number as value. 175 | *

The value of this variable can later be retrieved with {@link #getEnvVariable(String)}

176 | * @param portVariable an environment variable name 177 | * @return this builder 178 | */ 179 | public Builder addAutoPortVariable(String portVariable) { 180 | final int freePort = chooseFreePort(lastAutoPort); 181 | this.env.put(Objects.requireNonNull(portVariable), String.valueOf(freePort)); 182 | lastAutoPort = freePort; 183 | return this; 184 | } 185 | 186 | /** 187 | * Add a custom ready test as a supplier which can test if any external docker services are ready. 188 | *

The builder instance itself is provided as the function argument to be able to extract 189 | * values from auto-port variables, see {@link #getEnvVariable(String)}.

190 | *

The ready-test-supplier be called potentially multiple times at some interval and should 191 | * supply {@code true} when a service is ready.

192 | *

By default, a ready-test which simply waits 5 seconds is used.

193 | * @param readyTestSupplier a function which accepts this build as argument and returns a new ready-test supplier 194 | * @return this builder 195 | */ 196 | public Builder withCustomReadyTest(Function> readyTestSupplier) { 197 | Supplier readyTest = readyTestSupplier.apply(this); 198 | if (readyTest != null) { 199 | this.readyTests.add(readyTest); 200 | } 201 | return this; 202 | } 203 | 204 | /** 205 | * Set a directory where docker-compose stdout/stderr logs will be stored. 206 | *

Default is nothing, and no logs are kept.

207 | * @param directoryPath 208 | * @return this builder 209 | */ 210 | public Builder dockerComposeLogDir(String directoryPath) { 211 | this.logdir = directoryPath; 212 | return this; 213 | } 214 | 215 | /** 216 | * Set wait limit in seconds for docker-compose to finish bringing up containers. This time may involve 217 | * Docker downloading images from the internet, take that into account. 218 | *

Default value: {@code 1800} seconds

219 | * @return this builder 220 | */ 221 | public Builder dockerComposeTimeout(int timeoutSeconds) { 222 | this.dockerComposeTimeoutSeconds = timeoutSeconds; 223 | return this; 224 | } 225 | 226 | /** 227 | * Bring up configured env. This call will block for some amount of time to invoke docker-compose, and then 228 | * to wait using any set ready-test. 229 | * @return a hopefully ready-to-use docker-compose environment 230 | */ 231 | public DockerComposeEnv up() throws Exception { 232 | if (readyTests.isEmpty()) { 233 | readyAfter(5, TimeUnit.SECONDS); 234 | } 235 | return new DockerComposeEnv(this.config, this.dockerComposeTimeoutSeconds, this.logdir, this.env, this.readyTests).up(); 236 | } 237 | } 238 | 239 | /** 240 | * Get a builder for a docker-compose test environment. 241 | * @param dockerComposeConfigFile 242 | * @return 243 | */ 244 | static DockerComposeEnv.Builder builder(String dockerComposeConfigFile) { 245 | return new Builder(dockerComposeConfigFile); 246 | } 247 | 248 | private final Path dockerComposeLogDir; 249 | private final String configFile; 250 | private final long timeoutSeconds; 251 | private final Map env; 252 | private final List> readyTests; 253 | 254 | private DockerComposeEnv(String configFile, long timeoutSeconds, String logdir, Map env, List> readyTests) { 255 | if (logdir != null) { 256 | File dir = new File(logdir); 257 | if (!dir.isDirectory() || !dir.canWrite()) { 258 | throw new IllegalArgumentException("Invalid directory for logs (does not exist or is not writable): " + logdir); 259 | } 260 | this.dockerComposeLogDir = dir.toPath(); 261 | } else { 262 | this.dockerComposeLogDir = null; 263 | } 264 | this.configFile = configFile; 265 | this.timeoutSeconds = timeoutSeconds; 266 | this.env = env; 267 | this.readyTests = readyTests; 268 | } 269 | 270 | /** 271 | * Factory for constructing a new docker-compose test environment. 272 | *

Waits for all configured ready-tests to complete before returning.

273 | * 274 | * @return the new instance 275 | * @throws Exception in case something goes awry during setup. 276 | */ 277 | private DockerComposeEnv up() throws Exception { 278 | log.info("Bringing up local docker-compose environment, max wait={} seconds, env={}", this.timeoutSeconds, this.env); 279 | dockerCompose("up", "-d").start().onExit().thenAccept(process -> { 280 | if (process.exitValue() != 0) { 281 | throw new RuntimeException("docker-compose failed with status " + process.exitValue()); 282 | } 283 | }).get(this.timeoutSeconds, TimeUnit.SECONDS); 284 | 285 | log.info("Waiting for {} ready-test(s) to complete ..", readyTests.size()); 286 | List> allReadyTestsComplete = readyTests.stream().map( 287 | test -> CompletableFuture.runAsync(()-> { 288 | int count = 0; 289 | while (!test.get() && count++ < 300) { 290 | try { 291 | Thread.sleep(1000); 292 | } catch (InterruptedException ie) { 293 | throw new RuntimeException("Interrupted while waiting for ready-test"); 294 | } 295 | } 296 | if (count >= 300) { 297 | throw new RuntimeException("Ready-test give up after 300 attempts"); 298 | } 299 | } 300 | )).collect(Collectors.toList()); 301 | 302 | try { 303 | CompletableFuture.allOf(allReadyTestsComplete.toArray(new CompletableFuture[]{})) 304 | .thenRun(() -> log.info("All ready-tests completed.")) 305 | .get(240, TimeUnit.SECONDS); 306 | } catch (Exception e) { 307 | log.error("At least one ready-test failed, or we timed out while waiting", e); 308 | down(); 309 | throw e; 310 | } 311 | 312 | return this; 313 | } 314 | 315 | /** 316 | * Return value of a registered environment variable. 317 | * @param variable the variable 318 | * @return the value, or {@code null} if unknown 319 | */ 320 | public String getEnvVariable(String variable) { 321 | return env.get(variable); 322 | } 323 | 324 | /** 325 | * Take down local docker environment using docker-compose. 326 | * @throws Exception when something bad happens, in which case you will have to clean up manually. 327 | */ 328 | public void down() throws Exception { 329 | log.info("Taking down local docker-compose environment .."); 330 | dockerCompose("down", "--volumes").start().onExit().get(120, TimeUnit.SECONDS); 331 | } 332 | 333 | static class DockerComposeCommand { 334 | public final String executable; 335 | private final String[] args; 336 | public final String version; 337 | 338 | DockerComposeCommand(String executable, String[] args, String version) { 339 | this.executable = Objects.requireNonNull(executable); 340 | this.args = Objects.requireNonNull(args); 341 | this.version = Objects.requireNonNull(version); 342 | } 343 | 344 | List executableAndDefaultArgs() { 345 | return Arrays.stream(new String[][]{ 346 | new String[]{executable}, 347 | args, 348 | compareVersions(version, "1.29") < 0 ? new String[]{"--no-ansi"} : new String[]{"--ansi", "never"} 349 | }).flatMap(e -> Arrays.stream(e)).toList(); 350 | } 351 | 352 | private static int compareVersions(String v1, String v2) { 353 | List v1Components = Arrays.stream(v1.split("\\.")) 354 | .map(n -> Integer.parseInt(n)).collect(Collectors.toList()); 355 | List v2Components = Arrays.stream(v2.split("\\.")) 356 | .map(n -> Integer.parseInt(n)).collect(Collectors.toList()); 357 | 358 | final int maxLength = Math.max(v1Components.size(), v2Components.size()); 359 | for (int i=0; i commandArgs = new ArrayList<>(dcExec.executableAndDefaultArgs()); 437 | commandArgs.addAll(List.of("-f", Path.of(this.configFile).toString(), "-p", dockerComposeProjectName())); 438 | commandArgs.addAll(Arrays.asList(composeCommandAndArgs)); 439 | ProcessBuilder pb = new ProcessBuilder(commandArgs); 440 | this.env.forEach((k,v) -> pb.environment().put(k, v)); 441 | if (this.dockerComposeLogDir != null){ 442 | pb.redirectOutput(appendTo(this.dockerComposeLogDir.resolve(dockerComposeProjectName() + "-stdout.log").toFile())); 443 | pb.redirectError(appendTo(this.dockerComposeLogDir.resolve(dockerComposeProjectName() + "-stderr.log").toFile())); 444 | } else { 445 | pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); 446 | pb.redirectError(ProcessBuilder.Redirect.DISCARD); 447 | } 448 | 449 | return pb; 450 | } 451 | 452 | private String dockerComposeProjectName() { 453 | return getClass().getSimpleName().toLowerCase() + "-" + obtainPid(); 454 | } 455 | 456 | private static long obtainPid() { 457 | return ProcessHandle.current().pid(); 458 | } 459 | 460 | private static int chooseFreePort(int higherThan) { 461 | int next = Math.min(65535, higherThan + 1 + (int)(Math.random()*100)); 462 | do { 463 | try (ServerSocket s = ServerSocketFactory.getDefault().createServerSocket( 464 | next, 1, InetAddress.getLocalHost())) { 465 | return s.getLocalPort(); 466 | } catch (IOException e) { 467 | } 468 | } while ((next += (int)(Math.random()*100)) <= 65535); 469 | throw new IllegalStateException("Unable to find free network port on localhost"); 470 | } 471 | 472 | @Override 473 | public void close() throws Exception { 474 | down(); 475 | } 476 | 477 | } 478 | -------------------------------------------------------------------------------- /clients/src/test/java/no/nav/kafka/sandbox/DockerComposeEnvTest.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import no.nav.kafka.sandbox.DockerComposeEnv.DockerComposeCommand; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.util.List; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | 10 | class DockerComposeEnvTest { 11 | 12 | @Test 13 | void dockerComposeCommand() { 14 | assertEquals(List.of("docker-compose", "--no-ansi"), 15 | new DockerComposeCommand("docker-compose", new String[0], "1.28").executableAndDefaultArgs()); 16 | assertEquals(List.of("docker-compose", "--no-ansi"), 17 | new DockerComposeCommand("docker-compose", new String[0], "1").executableAndDefaultArgs()); 18 | 19 | assertEquals(List.of("docker-compose", "--ansi", "never"), 20 | new DockerComposeCommand("docker-compose", new String[0], "1.29").executableAndDefaultArgs()); 21 | assertEquals(List.of("docker", "compose", "--ansi", "never"), 22 | new DockerComposeCommand("docker", new String[]{"compose"}, "2").executableAndDefaultArgs()); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /clients/src/test/java/no/nav/kafka/sandbox/KafkaSandboxTest.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox; 2 | 3 | import no.nav.kafka.sandbox.messages.ConsoleMessages.Message; 4 | import no.nav.kafka.sandbox.consumer.JsonMessageConsumer; 5 | import no.nav.kafka.sandbox.producer.JsonMessageProducer; 6 | import org.apache.kafka.clients.admin.AdminClient; 7 | import org.apache.kafka.clients.admin.AdminClientConfig; 8 | import org.apache.kafka.clients.admin.NewTopic; 9 | import org.apache.kafka.clients.consumer.ConsumerConfig; 10 | import org.apache.kafka.clients.consumer.KafkaConsumer; 11 | import org.apache.kafka.clients.producer.KafkaProducer; 12 | import org.apache.kafka.clients.producer.ProducerConfig; 13 | import org.apache.kafka.clients.producer.ProducerRecord; 14 | import org.apache.kafka.common.errors.InterruptException; 15 | import org.apache.kafka.common.serialization.StringDeserializer; 16 | import org.apache.kafka.common.serialization.StringSerializer; 17 | import org.junit.jupiter.api.*; 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | 21 | import java.time.Duration; 22 | import java.util.ArrayList; 23 | import java.util.Collections; 24 | import java.util.List; 25 | import java.util.Map; 26 | import java.util.concurrent.*; 27 | import java.util.concurrent.atomic.AtomicInteger; 28 | import java.util.function.Supplier; 29 | 30 | import static org.junit.jupiter.api.Assertions.*; 31 | 32 | /** 33 | * Demonstrates use of {@link DockerComposeEnv}, and a few simple tests using a running Kafka instance. 34 | */ 35 | class KafkaSandboxTest { 36 | 37 | private static DockerComposeEnv dockerComposeEnv; 38 | 39 | private static AdminClient adminClient; 40 | 41 | private static Logger LOG = LoggerFactory.getLogger(KafkaSandboxTest.class); 42 | 43 | private final String testTopic; 44 | 45 | public KafkaSandboxTest() { 46 | testTopic = nextTestTopic(); 47 | } 48 | 49 | private static final AtomicInteger testTopicCounter = new AtomicInteger(0); 50 | private static String nextTestTopic() { 51 | return "test-topic-" + testTopicCounter.incrementAndGet(); 52 | } 53 | 54 | @BeforeAll 55 | static void dockerComposeUp() throws Exception { 56 | Assumptions.assumeTrue(DockerComposeEnv.dockerComposeAvailable(), 57 | "This test needs a working 'docker compose' command"); 58 | 59 | dockerComposeEnv = DockerComposeEnv.builder("src/test/resources/KafkaDockerComposeEnv.yml") 60 | .addAutoPortVariable("KAFKA_PORT") 61 | .dockerComposeLogDir("target/") 62 | .readyWhenPortIsOpen("localhost", "KAFKA_PORT") 63 | .up(); 64 | 65 | adminClient = newAdminClient(); 66 | } 67 | 68 | @AfterAll 69 | static void dockerComposeDown() throws Exception { 70 | if (dockerComposeEnv != null) { 71 | adminClient.close(); 72 | dockerComposeEnv.down(); 73 | } 74 | } 75 | 76 | static int kafkaPort() { 77 | return Integer.parseInt(dockerComposeEnv.getEnvVariable("KAFKA_PORT")); 78 | } 79 | 80 | @BeforeEach 81 | void createTestTopic() throws Exception { 82 | adminClient.createTopics(Collections.singleton(new NewTopic(testTopic, 1, (short)1))).all().get(); 83 | } 84 | 85 | @AfterEach 86 | void deleteTestTopic() throws Exception { 87 | adminClient.deleteTopics(Collections.singleton(testTopic)).all().get(); 88 | } 89 | 90 | @Test 91 | void waitForMessagesBeforeSending() throws Exception { 92 | LOG.debug("waitForMessagesBeforeSending start"); 93 | final List messages = List.of("one", "two", "three", "four"); 94 | 95 | final CountDownLatch waitForAllMessages = new CountDownLatch(messages.size()); 96 | 97 | Executors.newSingleThreadExecutor().execute(() -> { 98 | try (KafkaConsumer consumer = newConsumer("test-group")) { 99 | consumer.subscribe(Collections.singleton(testTopic)); 100 | int pollCount = 0; 101 | while (waitForAllMessages.getCount() > 0 && pollCount++ < 5) { 102 | consumer.poll(Duration.ofSeconds(10)).forEach(record -> { 103 | LOG.debug("Received message: " + record.value()); 104 | waitForAllMessages.countDown(); 105 | }); 106 | } 107 | } 108 | }); 109 | 110 | // Consumer is now ready and waiting for data, produce something without waiting for acks 111 | try (KafkaProducer producer = newProducer()) { 112 | messages.forEach(val -> { 113 | LOG.debug("Sending message: " + val); 114 | producer.send(new ProducerRecord<>(testTopic, val)); 115 | try { 116 | TimeUnit.SECONDS.sleep(1); 117 | } catch (InterruptedException ie) { 118 | } 119 | }); 120 | } 121 | 122 | // Wait for consumer to receive the expected number of messages from Kafka 123 | assertTrue(waitForAllMessages.await(30, TimeUnit.SECONDS), "Did not receive expected number of messages within time frame"); 124 | } 125 | 126 | @Test 127 | void sendThenReceiveMessages() throws Exception { 128 | LOG.debug("sendThenReceiveMessage start"); 129 | final List messages = List.of("one", "two", "three", "four"); 130 | 131 | final CountDownLatch productionFinished = new CountDownLatch(messages.size()); 132 | try (KafkaProducer producer = newProducer()) { 133 | messages.forEach(val -> { 134 | producer.send(new ProducerRecord<>(testTopic, val), (metadata, exception) -> { 135 | if (exception == null) { 136 | productionFinished.countDown(); 137 | } 138 | }); 139 | }); 140 | productionFinished.await(30, TimeUnit.SECONDS); // Wait for shipment of messages 141 | } 142 | 143 | final List received = new ArrayList<>(); 144 | try (KafkaConsumer consumer = newConsumer("test-group")) { 145 | consumer.subscribe(Collections.singleton(testTopic)); 146 | int pollCount = 0; 147 | while (received.size() < messages.size() && pollCount++ < 10) { 148 | consumer.poll(Duration.ofSeconds(1)).forEach(cr -> { 149 | received.add(cr.value()); 150 | }); 151 | } 152 | } 153 | 154 | LOG.debug("Received messages: {}", received); 155 | 156 | assertEquals(messages.size(), received.size(), "Number of messages received not equal to number of messages sent"); 157 | } 158 | 159 | @Test 160 | void testJsonMessageConsumerAndProducer() throws Exception { 161 | 162 | final BlockingQueue outbox = new ArrayBlockingQueue<>(1); 163 | final Supplier supplier = () -> { 164 | try { return outbox.take(); } catch (InterruptedException ie) { 165 | throw new InterruptException(ie); 166 | } 167 | }; 168 | 169 | final ExecutorService executor = Executors.newFixedThreadPool(2); 170 | 171 | final var producer = new JsonMessageProducer(testTopic, null, kafkaProducerTestConfig(kafkaPort()), 172 | Bootstrap.objectMapper(), supplier , m -> null, true); 173 | final Future producerLoop = executor.submit(producer::produceLoop); 174 | 175 | outbox.offer(new Message("Hello", "test-sender")); 176 | 177 | // ---- now fetch message using consumer ---- 178 | 179 | final BlockingQueue inbox = new ArrayBlockingQueue<>(1); 180 | final var consumer = new JsonMessageConsumer(testTopic, 181 | Message.class, 182 | kafkaConsumerTestConfig(kafkaPort(), "testGroup"), 183 | Bootstrap.objectMapper(), m -> inbox.offer(m) ); 184 | final Future consumeLoop = executor.submit(consumer::consumeLoop); 185 | 186 | Message success = inbox.take(); 187 | assertEquals("Hello", success.text); 188 | assertNull(inbox.poll()); 189 | 190 | producerLoop.cancel(true); 191 | consumeLoop.cancel(true); 192 | executor.shutdown(); 193 | } 194 | 195 | private static AdminClient newAdminClient() { 196 | return AdminClient.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:"+ kafkaPort())); 197 | } 198 | 199 | private static KafkaConsumer newConsumer(String group) { 200 | return new KafkaConsumer<>(kafkaConsumerTestConfig(kafkaPort(), group)); 201 | } 202 | 203 | private static KafkaProducer newProducer() { 204 | return new KafkaProducer<>(kafkaProducerTestConfig(kafkaPort())); 205 | } 206 | 207 | private static Map kafkaConsumerTestConfig(int kafkaPort, String consumerGroup) { 208 | return Map.of( 209 | ConsumerConfig.GROUP_ID_CONFIG, consumerGroup, 210 | ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:" + kafkaPort, 211 | ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName(), 212 | ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName(), 213 | ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest" 214 | ); 215 | } 216 | 217 | private static Map kafkaProducerTestConfig(int kafkaPort) { 218 | return Map.of( 219 | ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:" + kafkaPort, 220 | ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName(), 221 | ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName() 222 | ); 223 | } 224 | 225 | } 226 | -------------------------------------------------------------------------------- /clients/src/test/resources/KafkaDockerComposeEnv.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | services: 4 | zookeeper: 5 | hostname: zookeeper 6 | container_name: zookeeper-test-${KAFKA_PORT} 7 | image: confluentinc/cp-zookeeper:latest 8 | environment: 9 | ZOOKEEPER_CLIENT_PORT: 2181 10 | ZOOKEEPER_TICK_TIME: 2000 11 | healthcheck: 12 | test: ["CMD-SHELL", "echo ruok | nc -w 2 localhost 2181"] 13 | interval: 10s 14 | timeout: 30s 15 | retries: 5 16 | 17 | broker: 18 | image: confluentinc/cp-kafka:latest 19 | hostname: broker 20 | container_name: broker-test-${KAFKA_PORT} 21 | ports: 22 | - "${KAFKA_PORT:-9092}:${KAFKA_PORT:-9092}" 23 | depends_on: 24 | - zookeeper 25 | environment: 26 | KAFKA_BROKER_ID: 1 27 | KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' 28 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT 29 | KAFKA_ADVERTISED_LISTENERS: INTERNAL://broker:29092,EXTERNAL://localhost:${KAFKA_PORT:-9092} 30 | KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL 31 | CONFLUENT_METRICS_REPORTER_BOOTSTRAP_SERVERS: broker:29092 32 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 33 | KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 34 | healthcheck: 35 | test: ["CMD-SHELL", "echo healthcheck | kafka-console-producer --broker-list broker:29092 --topic healthchecktopic"] 36 | interval: 10s 37 | timeout: 30s 38 | retries: 5 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | services: 4 | zookeeper: 5 | hostname: zookeeper 6 | container_name: zookeeper 7 | image: confluentinc/cp-zookeeper:latest 8 | ports: 9 | - "${ZK_PORT:-2181}" 10 | environment: 11 | ZOOKEEPER_CLIENT_PORT: ${ZK_PORT:-2181} 12 | ZOOKEEPER_TICK_TIME: 2000 13 | healthcheck: 14 | test: ["CMD-SHELL", "echo ruok | nc -w 2 localhost ${ZK_PORT:-2181}"] 15 | interval: 30s 16 | timeout: 30s 17 | retries: 5 18 | 19 | broker: 20 | image: confluentinc/cp-kafka:latest 21 | hostname: broker 22 | container_name: broker 23 | ports: 24 | - "${KAFKA_PORT:-9092}:${KAFKA_PORT:-9092}" 25 | - "29092" 26 | depends_on: 27 | - zookeeper 28 | environment: 29 | KAFKA_BROKER_ID: 1 30 | KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:${ZK_PORT:-2181}' 31 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT 32 | KAFKA_ADVERTISED_LISTENERS: INTERNAL://broker:29092,EXTERNAL://localhost:${KAFKA_PORT:-9092} 33 | KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL 34 | CONFLUENT_METRICS_REPORTER_BOOTSTRAP_SERVERS: broker:29092 35 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 36 | KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 37 | healthcheck: 38 | test: ["CMD-SHELL", "echo healthcheck | kafka-console-producer --broker-list localhost:${KAFKA_PORT:-9092} --topic healthchecktopic"] 39 | interval: 30s 40 | timeout: 30s 41 | retries: 5 42 | -------------------------------------------------------------------------------- /messages/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | kafka-sandbox 9 | no.nav.kafka 10 | 1.0-SNAPSHOT 11 | 12 | 13 | messages 14 | 15 | 16 | 17 | com.fasterxml.jackson.core 18 | jackson-annotations 19 | 20 | 21 | org.fusesource.jansi 22 | jansi 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /messages/src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | open module no.nav.kafka.sandbox.messages { 2 | requires com.fasterxml.jackson.annotation; 3 | requires org.fusesource.jansi; 4 | exports no.nav.kafka.sandbox.messages; 5 | } 6 | -------------------------------------------------------------------------------- /messages/src/main/java/no/nav/kafka/sandbox/messages/ConsoleMessages.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.messages; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import org.fusesource.jansi.AnsiConsole; 6 | import org.fusesource.jansi.AnsiRenderer; 7 | 8 | import java.io.Console; 9 | import java.util.Objects; 10 | import java.util.concurrent.atomic.AtomicBoolean; 11 | import java.util.function.Consumer; 12 | import java.util.function.Supplier; 13 | 14 | public class ConsoleMessages { 15 | 16 | public static class Message { 17 | @JsonProperty 18 | public final String text; 19 | @JsonProperty 20 | public final String senderId; 21 | 22 | @JsonCreator 23 | public Message(@JsonProperty("text") String text, 24 | @JsonProperty("senderId") String id) { 25 | this.text = text; 26 | this.senderId = id; 27 | } 28 | 29 | @Override 30 | public boolean equals(Object o) { 31 | if (this == o) return true; 32 | if (o == null || getClass() != o.getClass()) return false; 33 | Message message = (Message) o; 34 | return Objects.equals(text, message.text) && Objects.equals(senderId, message.senderId); 35 | } 36 | 37 | @Override 38 | public int hashCode() { 39 | return Objects.hash(text, senderId); 40 | } 41 | } 42 | 43 | public static Consumer consoleMessageConsumer() { 44 | return message -> AnsiConsole.out().println( 45 | AnsiRenderer.render(String.format("@|magenta,bold %s|@: @|yellow %s|@", message.senderId, message.text))); 46 | } 47 | 48 | public static Supplier consoleMessageSupplier() { 49 | final Console console = System.console(); 50 | if (console == null) { 51 | throw new IllegalStateException("Unable to get system console"); 52 | } 53 | final String senderId = "sender-" + ProcessHandle.current().pid(); 54 | final AtomicBoolean first = new AtomicBoolean(true); 55 | return () -> { 56 | if (first.getAndSet(false)) { 57 | console.writer().println("Send messages to Kafka, use CTRL+D to exit gracefully."); 58 | } 59 | try { 60 | Thread.sleep(200); 61 | } catch (InterruptedException ie) { 62 | Thread.currentThread().interrupt(); 63 | return new Message("interrupted", senderId); 64 | } 65 | console.writer().print("Type message> "); 66 | console.writer().flush(); 67 | String text = console.readLine(); 68 | if (text == null){ 69 | Thread.currentThread().interrupt(); 70 | return new Message("leaving", senderId); 71 | } else { 72 | return new Message(text, senderId); 73 | } 74 | }; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /messages/src/main/java/no/nav/kafka/sandbox/messages/Measurements.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.messages; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import org.fusesource.jansi.AnsiConsole; 6 | import org.fusesource.jansi.AnsiRenderer; 7 | 8 | import java.time.LocalDateTime; 9 | import java.time.OffsetDateTime; 10 | import java.util.NoSuchElementException; 11 | import java.util.Objects; 12 | import java.util.concurrent.atomic.AtomicInteger; 13 | import java.util.function.Supplier; 14 | 15 | public class Measurements { 16 | 17 | public static class SensorEvent { 18 | 19 | private final String deviceId; 20 | private final String measureType; 21 | private final String unitType; 22 | private final OffsetDateTime timestamp; 23 | private final Integer value; 24 | 25 | @JsonCreator 26 | public SensorEvent(@JsonProperty("deviceId") String deviceId, 27 | @JsonProperty("measureType") String measureType, 28 | @JsonProperty("unitType") String unitType, 29 | @JsonProperty("timestamp") OffsetDateTime timestamp, 30 | @JsonProperty("value") Integer value) { 31 | this.deviceId = Objects.requireNonNull(deviceId); 32 | this.measureType = Objects.requireNonNull(measureType); 33 | this.unitType = Objects.requireNonNull(unitType); 34 | this.timestamp = Objects.requireNonNull(timestamp); 35 | this.value = Objects.requireNonNull(value); 36 | } 37 | 38 | public String getDeviceId() { 39 | return deviceId; 40 | } 41 | 42 | public String getMeasureType() { 43 | return measureType; 44 | } 45 | 46 | public String getUnitType() { 47 | return unitType; 48 | } 49 | 50 | public OffsetDateTime getTimestamp() { 51 | return timestamp; 52 | } 53 | 54 | public Integer getValue() { 55 | return value; 56 | } 57 | 58 | @Override 59 | public String toString() { 60 | return "SensorEvent{" + 61 | "deviceId='" + deviceId + '\'' + 62 | ", measureType='" + measureType + '\'' + 63 | ", unitType='" + unitType + '\'' + 64 | ", timestamp=" + timestamp + 65 | ", value=" + value + 66 | '}'; 67 | } 68 | 69 | @Override 70 | public boolean equals(Object o) { 71 | if (this == o) return true; 72 | if (o == null || getClass() != o.getClass()) return false; 73 | SensorEvent that = (SensorEvent) o; 74 | return Objects.equals(deviceId, that.deviceId) 75 | && Objects.equals(measureType, that.measureType) 76 | && Objects.equals(unitType, that.unitType) 77 | && Objects.equals(timestamp, that.timestamp) 78 | && Objects.equals(value, that.value); 79 | } 80 | 81 | @Override 82 | public int hashCode() { 83 | return Objects.hash(deviceId, measureType, unitType, timestamp, value); 84 | } 85 | } 86 | 87 | /** 88 | * @param maxNumberOfElements max number of elements to supply 89 | * @return a sensor event supplier providing up to n values. 90 | * @throws NoSuchElementException when no more events can be supplied. 91 | */ 92 | public static Supplier eventSupplier(final int maxNumberOfElements) { 93 | final AtomicInteger counter = new AtomicInteger(); 94 | return () -> { 95 | if (counter.incrementAndGet() > maxNumberOfElements) { 96 | throw new NoSuchElementException("No more data can be supplied"); 97 | } 98 | return generateEvent(); 99 | }; 100 | } 101 | 102 | /** 103 | * @return a sensor event supplier providing infinite number of values with a delay. 104 | */ 105 | public static Supplier delayedInfiniteEventSupplier() { 106 | return () -> { 107 | try { 108 | Thread.sleep((long) (Math.random() * 1000) + 1000); 109 | } catch (InterruptedException ie) { 110 | Thread.currentThread().interrupt(); 111 | } 112 | return generateEvent(); 113 | }; 114 | } 115 | 116 | /** 117 | * @return a generated sensor event 118 | */ 119 | public static SensorEvent generateEvent() { 120 | final long pid = ProcessHandle.current().pid(); 121 | final int temperatureBase = (int)(pid % 100); 122 | final int temperaturVariance = (int)(pid % 10); 123 | final String sensorId = "sensor-" + pid; 124 | 125 | final int temp = (int)(Math.random()*temperaturVariance + temperatureBase); 126 | return new SensorEvent(sensorId,"temperature", "celcius", OffsetDateTime.now(), temp); 127 | } 128 | 129 | public static void sensorEventToConsole(SensorEvent m) { 130 | final String ansiOutput = AnsiRenderer.render(String.format( 131 | "@|cyan Device|@: @|magenta,bold %s|@, value: @|blue,bold %d\u00B0|@ %s, timestamp: @|green %s|@", 132 | m.getDeviceId(), m.getValue(), m.getUnitType(), m.getTimestamp())); 133 | 134 | AnsiConsole.out().println(ansiOutput); 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /messages/src/main/java/no/nav/kafka/sandbox/messages/SequenceValidation.java: -------------------------------------------------------------------------------- 1 | package no.nav.kafka.sandbox.messages; 2 | 3 | import org.fusesource.jansi.AnsiConsole; 4 | import org.fusesource.jansi.AnsiRenderer; 5 | 6 | import java.io.*; 7 | import java.util.concurrent.TimeUnit; 8 | import java.util.concurrent.atomic.AtomicLong; 9 | import java.util.function.Consumer; 10 | import java.util.function.Supplier; 11 | 12 | /** 13 | * Supplier and consumer that does message sequence validation. 14 | * 15 | *

Can be used to test loss or reordering of Kafka messages

16 | * 17 | *

The supplier maintains state on disk wrt. next sequence number. Delete the persistence file to reset state.

18 | */ 19 | public class SequenceValidation { 20 | 21 | public static Supplier sequenceSupplier(File persistence, long delay, TimeUnit timeUnit) { 22 | return new SequenceSupplier(persistence, delay, timeUnit); 23 | } 24 | 25 | public static Consumer sequenceValidatorConsolePrinter() { 26 | return new SequenceValidator(); 27 | } 28 | 29 | private static class SequenceSupplier implements Supplier { 30 | final AtomicLong sequence; 31 | final long delayMillis; 32 | final File persistence; 33 | 34 | private SequenceSupplier(File persistence, long delay, TimeUnit timeUnit) { 35 | this.delayMillis = timeUnit.toMillis(delay); 36 | this.persistence = persistence; 37 | this.sequence = new AtomicLong(loadValue(0)); 38 | } 39 | 40 | private void saveValue(long value) { 41 | try (DataOutputStream out = new DataOutputStream(new FileOutputStream(persistence))) { 42 | out.writeLong(value); 43 | } catch (Exception e) {} 44 | } 45 | 46 | private long loadValue(long defaultValue) { 47 | try (DataInputStream in = new DataInputStream(new FileInputStream(persistence))) { 48 | return in.readLong(); 49 | } catch (Exception e) { 50 | return defaultValue; 51 | } 52 | } 53 | 54 | @Override 55 | public Long get() { 56 | try { 57 | Thread.sleep(delayMillis); 58 | } catch (InterruptedException ie) { 59 | Thread.currentThread().interrupt(); 60 | } 61 | final long toBeDelivered = sequence.longValue(); 62 | toConsole("@|cyan [SEQ]|@ Supplying sequence(%d)", toBeDelivered); 63 | saveValue(sequence.incrementAndGet()); 64 | return toBeDelivered; 65 | } 66 | } 67 | 68 | private static class SequenceValidator implements Consumer { 69 | 70 | long expect = -1; 71 | long errorCount = 0; 72 | 73 | @Override 74 | public void accept(Long received) { 75 | if (expect == -1) { 76 | expect = received + 1; 77 | toConsole("@|cyan [SEQ]|@ @|yellow Synchronized sequence|@: received(%d), next expect(%d), errors(%d)", received, expect, errorCount); 78 | } else if (received < expect) { 79 | ++errorCount; 80 | toConsole("@|cyan [SEQ]|@ @|red ERROR|@: Received lower sequence than expected: received(%d) < expect(%d), resync next, errors(%d)", 81 | received, expect, errorCount); 82 | expect = -1; 83 | } else if (received > expect){ 84 | ++errorCount; 85 | toConsole("@|cyan [SEQ]|@ @|red ERROR|@: Received higher sequence than expected: received(%d) > expect(%d), resync next, errors(%d)", 86 | received, expect, errorCount); 87 | expect = -1; 88 | } else { 89 | toConsole("@|cyan [SEQ]|@ @|blue,bold In sync|@: received(%d) = expect(%d), errors(%d)", received, expect, errorCount); 90 | ++expect; 91 | } 92 | } 93 | } 94 | 95 | private static void toConsole(String format, Object...args) { 96 | AnsiConsole.out().println(AnsiRenderer.render(String.format(format, args))); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | no.nav.kafka 8 | kafka-sandbox 9 | 1.0-SNAPSHOT 10 | pom 11 | 12 | 13 | messages 14 | clients 15 | clients-spring 16 | 17 | 18 | 19 | UTF-8 20 | 17 21 | 22 | 24 | 3.2.0 25 | 2.4.1 26 | 27 | 28 | 3.3.0 29 | 3.11.0 30 | 3.2.2 31 | 3.4.1 32 | 3.6.1 33 | 34 | 35 | 36 | 37 | 38 | org.springframework.boot 39 | spring-boot-dependencies 40 | ${spring-boot.version} 41 | pom 42 | import 43 | 44 | 45 | org.fusesource.jansi 46 | jansi 47 | ${jansi.version} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | org.apache.maven.plugins 57 | maven-compiler-plugin 58 | ${maven-compiler-plugin.version} 59 | 60 | ${java.version} 61 | true 62 | 63 | 64 | 65 | org.apache.maven.plugins 66 | maven-surefire-plugin 67 | ${maven-surefire-plugin.version} 68 | 69 | 70 | org.apache.maven.plugins 71 | maven-dependency-plugin 72 | ${maven-dependency-plugin.version} 73 | 74 | 75 | org.apache.maven.plugins 76 | maven-jar-plugin 77 | ${maven-jar-plugin.version} 78 | 79 | 80 | org.springframework.boot 81 | spring-boot-maven-plugin 82 | ${spring-boot.version} 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-enforcer-plugin 87 | ${maven-enforcer-plugin.version} 88 | 89 | 90 | enforce-versions 91 | 92 | enforce 93 | 94 | 95 | 96 | 97 | [${java.version},) 98 | 99 | 100 | [3.8,) 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | org.apache.maven.plugins 113 | maven-enforcer-plugin 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")" 5 | 6 | if ! test -f messages/target/messages-*.jar -a -f clients/target/clients-*.jar; then 7 | mvn -B install 8 | fi 9 | 10 | exec java -jar clients/target/clients-*.jar "$@" 11 | --------------------------------------------------------------------------------