From a2806fe6d21fd40f7dfbf731c5fe01d5381c4c5c Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Fri, 23 May 2014 16:37:15 +0200 Subject: [PATCH 01/12] Initial commit --- .gitignore | 13 + LICENSE | 13 + README.md | 790 ++++++++++++++++++ assembly.sbt | 16 + build.sbt | 87 ++ images/IntelliJ-IDEA-Avro-bug.png | Bin 0 -> 115820 bytes images/IntelliJ-IDEA-Avro-bug_400x216.png | Bin 0 -> 50156 bytes project/assembly.sbt | 1 + project/build.properties | 1 + project/build.sbt | 2 + project/plugins.sbt | 20 + sbt | 457 ++++++++++ sonar-project.properties | 26 + src/main/avro/twitter.avsc | 19 + src/main/resources/broker-defaults.properties | 31 + .../resources/consumer-defaults.properties | 110 +++ .../resources/producer-defaults.properties | 117 +++ .../kafkastorm/kafka/KafkaConsumer.scala | 86 ++ .../kafkastorm/kafka/KafkaEmbedded.scala | 68 ++ .../kafkastorm/kafka/KafkaProducerApp.scala | 77 ++ .../kafkastorm/storm/AvroDecoderBolt.scala | 106 +++ .../kafkastorm/storm/AvroKafkaSinkBolt.scala | 104 +++ .../miguno/kafkastorm/storm/AvroScheme.scala | 80 ++ .../kafkastorm/storm/KafkaStormDemo.scala | 121 +++ .../storm/TweetAvroKryoDecorator.scala | 14 + .../kafkastorm/storm/utils/StormRunner.scala | 24 + .../zookeeper/ZooKeeperEmbedded.scala | 36 + src/test/resources/log4j.properties | 87 ++ .../integration/IntegrationSuite.scala | 9 + .../integration/IntegrationTest.scala | 5 + .../kafkastorm/integration/KafkaSpec.scala | 218 +++++ .../integration/KafkaStormSpec.scala | 317 +++++++ .../kafkastorm/integration/StormSpec.scala | 113 +++ .../kafka/KafkaProducerAppSpec.scala | 59 ++ .../storm/AvroDecoderBoltSpec.scala | 144 ++++ .../storm/AvroKafkaSinkBoltSpec.scala | 113 +++ .../kafkastorm/storm/AvroSchemeSpec.scala | 96 +++ .../kafkastorm/storm/FieldsEqualTo.scala | 30 + version.sbt | 1 + 39 files changed, 3611 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assembly.sbt create mode 100644 build.sbt create mode 100644 images/IntelliJ-IDEA-Avro-bug.png create mode 100644 images/IntelliJ-IDEA-Avro-bug_400x216.png create mode 100644 project/assembly.sbt create mode 100644 project/build.properties create mode 100644 project/build.sbt create mode 100644 project/plugins.sbt create mode 100755 sbt create mode 100644 sonar-project.properties create mode 100644 src/main/avro/twitter.avsc create mode 100644 src/main/resources/broker-defaults.properties create mode 100644 src/main/resources/consumer-defaults.properties create mode 100644 src/main/resources/producer-defaults.properties create mode 100644 src/main/scala/com/miguno/kafkastorm/kafka/KafkaConsumer.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/kafka/KafkaEmbedded.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/kafka/KafkaProducerApp.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/AvroDecoderBolt.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBolt.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/AvroScheme.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/storm/utils/StormRunner.scala create mode 100644 src/main/scala/com/miguno/kafkastorm/zookeeper/ZooKeeperEmbedded.scala create mode 100644 src/test/resources/log4j.properties create mode 100644 src/test/scala/com/miguno/kafkastorm/integration/IntegrationSuite.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/integration/IntegrationTest.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/integration/StormSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/kafka/KafkaProducerAppSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/storm/AvroDecoderBoltSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBoltSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala create mode 100644 src/test/scala/com/miguno/kafkastorm/storm/FieldsEqualTo.scala create mode 100644 version.sbt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7d53a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.class +*.log + +# sbt specific +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ + +# Kafka +logs/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..952b8cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright © 2014 Michael G. Noll + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..07fd587 --- /dev/null +++ b/README.md @@ -0,0 +1,790 @@ +# kafka-storm-starter + +Code examples that show how to integrate +[Apache Kafka](http://kafka.apache.org/) 0.8+ (latest stable) with +[Apache Storm](http://storm.incubator.apache.org/) 0.9+ (latest stable), +while using [Apache Avro](http://avro.apache.org/) as the data serialization format. + +--- + +Table of Contents + +* Quick start +* Features +* Implementation details +* Development + * Build requirements + * Building the code + * Running the tests + * Creating code coverage reports + * Packaging the code + * IDE support +* FAQ + * Kafka + * Storm +* Known issues and limitations + * Upstream code + * kafka-storm-starter code +* Contributing +* License +* References + * Wirbelsturm + * Kafka + * Storm + * Avro + * Kryo + +--- + + + + +# Quick start + +## Show me! + + $ ./sbt test + +This command launches our test suite. + +Notably it will run end-to-end tests of Kafka, Storm, and Kafka-Storm integration. See this abridged version of the +test output: + +``` +[...other tests removed...] + +[info] KafkaSpec: +[info] Kafka +[info] - should synchronously send and receive a Tweet in Avro format +[info] + Given a ZooKeeper instance +[info] + And a Kafka broker instance +[info] + And some tweets +[info] + And a single-threaded Kafka consumer group +[info] + When I start a synchronous Kafka producer that sends the tweets in Avro binary format +[info] + Then the consumer app should receive the tweets +[info] - should asynchronously send and receive a Tweet in Avro format +[info] + Given a ZooKeeper instance +[info] + And a Kafka broker instance +[info] + And some tweets +[info] + And a single-threaded Kafka consumer group +[info] + When I start an asynchronous Kafka producer that sends the tweets in Avro binary format +[info] + Then the consumer app should receive the tweets +[info] StormSpec: +[info] Storm +[info] - should start a local cluster +[info] + Given no cluster +[info] + When I start a LocalCluster instance +[info] + Then the local cluster should start properly +[info] - should run a basic topology +[info] + Given a local cluster +[info] + And a wordcount topology +[info] + And the input words alice, bob, joe, alice +[info] + When I submit the topology +[info] + Then the topology should properly count the words +[info] KafkaStormSpec: +[info] Feature: AvroDecoderBolt[T] +[info] Scenario: User creates a Storm topology that uses AvroDecoderBolt +[info] Given a ZooKeeper instance +[info] And a Kafka broker instance +[info] And a Storm topology that uses AvroDecoderBolt and that reads tweets from topic testing-input and writes them as-is to topic testing-output +[info] And some tweets +[info] And a synchronous Kafka producer app that writes to the topic testing-input +[info] And a single-threaded Kafka consumer app that reads from topic testing-output +[info] And a Storm topology configuration that registers an Avro Kryo decorator for Tweet +[info] When I run the Storm topology +[info] And I use the Kafka producer app to Avro-encode the tweets and sent them to Kafka +[info] Then the Kafka consumer app should receive the decoded, original tweets from the Storm topology +[info] Feature: AvroScheme[T] for Kafka spout +[info] Scenario: User creates a Storm topology that uses AvroScheme in Kafka spout +[info] Given a ZooKeeper instance +[info] And a Kafka broker instance +[info] And a Storm topology that uses AvroScheme and that reads tweets from topic testing-input and writes them as-is to topic testing-output +[info] And some tweets +[info] And a synchronous Kafka producer app that writes to the topic testing-input +[info] And a single-threaded Kafka consumer app that reads from topic testing-output +[info] And a Storm topology configuration that registers an Avro Kryo decorator for Tweet +[info] When I run the Storm topology +[info] And I use the Kafka producer app to Avro-encode the tweets and sent them to Kafka +[info] Then the Kafka consumer app should receive the decoded, original tweets from the Storm topology +[info] Run completed in 21 seconds, 852 milliseconds. +[info] Total number of tests run: 25 +[info] Suites: completed 8, aborted 0 +[info] Tests: succeeded 25, failed 0, canceled 0, ignored 0, pending 0 +[info] All tests passed. +[success] Total time: 22 s, completed May 23, 2014 12:31:09 PM +``` + + +## Show me one more time! + + $ ./sbt run + +This command launches [KafkaStormDemo](src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala). This demo +starts in-memory instances of ZooKeeper, Kafka, and Storm. It then runs a demo Storm topology that connects to and +reads from the Kafka instance. + +You will see output similar to the following (some parts removed to improve readability): + +``` +7031 [Thread-19] INFO backtype.storm.daemon.worker - Worker 3f7f1a51-5c9e-43a5-b431-e39a7272215e for storm kafka-storm-starter-1-1400839826 on daa60807-d440-4b45-94fc-8dd7798453d2:1027 has finished loading +7033 [Thread-29-kafka-spout] INFO storm.kafka.DynamicBrokersReader - Read partition info from zookeeper: GlobalPartitionInformation{partitionMap={0=127.0.0.1:9092}} +7050 [Thread-29-kafka-spout] INFO backtype.storm.daemon.executor - Opened spout kafka-spout:(1) +7051 [Thread-29-kafka-spout] INFO backtype.storm.daemon.executor - Activating spout kafka-spout:(1) +7051 [Thread-29-kafka-spout] INFO storm.kafka.ZkCoordinator - Refreshing partition manager connections +7065 [Thread-29-kafka-spout] INFO storm.kafka.DynamicBrokersReader - Read partition info from zookeeper: GlobalPartitionInformation{partitionMap={0=127.0.0.1:9092}} +7066 [Thread-29-kafka-spout] INFO storm.kafka.ZkCoordinator - Deleted partition managers: [] +7066 [Thread-29-kafka-spout] INFO storm.kafka.ZkCoordinator - New partition managers: [Partition{host=127.0.0.1:9092, partition=0}] +7083 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Read partition information from: /kafka-spout/kafka-storm-starter/partition_0 --> null +7100 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - No partition information found, using configuration to determine offset +7105 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Starting Kafka 127.0.0.1:0 from offset 18 +7106 [Thread-29-kafka-spout] INFO storm.kafka.ZkCoordinator - Finished refreshing +7126 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Committing offset for Partition{host=127.0.0.1:9092, partition=0} +7126 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Committed offset 18 for Partition{host=127.0.0.1:9092, partition=0} for topology: 47e82e34-fb36-427e-bde6-8cd971db2527 +9128 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Committing offset for Partition{host=127.0.0.1:9092, partition=0} +9129 [Thread-29-kafka-spout] INFO storm.kafka.PartitionManager - Committed offset 18 for Partition{host=127.0.0.1:9092, partition=0} for topology: 47e82e34-fb36-427e-bde6-8cd971db2527 +``` + +At this point Storm is connected to Kafka (more precisely: to the `testing` topic in Kafka). The last few lines from +above -- "Committing offset ..." --- will be repeated again and again, because a) this demo Storm topology only reads +from the Kafka topic but it does nothing to the data that was read and b) because we are not sending any data to the +Kafka topic. + +Note that this example will actually run _two_ in-memory instances of ZooKeeper: the first (listening at +`127.0.0.1:2181/tcp`) is used by the Kafka instance, the second (listening at `127.0.0.1:2000/tcp`) is automatically +started and used by the in-memory Storm cluster. This is because, when running in local aka in-memory mode, Storm does +not allow you to reconfigure or disable its own ZooKeeper instance (see the [Storm FAQ](#FAQ-Storm) below for further +information). + +**To stop the demo application you must kill or `Ctrl-C` the process in the terminal.** + +You can use [KafkaStormDemo](src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala) as a starting point to +create your own, "real" Storm topologies that read from a "real" Kafka, Storm, and ZooKeeper infrastructure. An easy +way to get started with such an infrastructure is by deploying Kafka, Storm, and ZooKeeper via a tool such as +[Wirbelsturm](https://github.com/miguno/wirbelsturm). + + + + + +# Features + +What features do we showcase in kafka-storm-starter? Note that we focus on showcasing, and not necessarily on +"production ready". + +* How to integrate Kafka and Storm. +* How to use [Avro](http://avro.apache.org/) with Kafka and Storm. +* Kafka standalone code examples + * [KafkaProducerApp](src/main/scala/com/miguno/kafkastorm/kafka/KafkaProducerApp.scala): + A simple Kafka producer app for writing Avro-encoded data into Kafka. + [KafkaSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala) puts this producer to use and shows + how to use Twitter Bijection to Avro-encode the messages being sent to Kafka. + * [KafkaConsumer](src/main/scala/com/miguno/kafkastorm/kafka/KafkaConsumer.scala): + A simple Kafka consumer app for reading Avro-encoded data from Kafka. + [KafkaSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala) puts this consumer to use and shows + how to use Twitter Bijection to Avro-decode the messages being read from Kafka. +* Storm standalone code examples + * [AvroDecoderBolt[T]](src/main/scala/com/miguno/kafkastorm/storm/AvroDecoderBolt.scala): + An `AvroDecoderBolt[T <: org.apache.avro.specific.SpecificRecordBase]` that can be parameterized with the type of + the Avro record `T` it will deserialize its data to (i.e. no need to write another decoder bolt just because the + bolt needs to handle a different Avro schema). + * [AvroScheme[T]](src/main/scala/com/miguno/kafkastorm/storm/AvroScheme.scala): + An `AvroScheme[T <: org.apache.avro.specific.SpecificRecordBase]` scheme, i.e. a custom + `backtype.storm.spout.Scheme` to auto-deserialize a spout's incoming data. The scheme can be parameterized with + the type of the Avro record `T` it will deserializes its data to (i.e. no need to write another scheme just + because the scheme needs to handle a different Avro schema). + * You can opt to configure a spout (such as the Kafka spout) with `AvroScheme` if you want to perform the Avro + decoding step directly in the spout instead of placing an `AvroDecoderBolt` after the Kafka spout. You may + want to profile your topology which of the two approaches works best for your use case. + * [TweetAvroKryoDecorator](src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala): + A custom `backtype.storm.serialization.IKryoDecorator`, i.e. a custom + [Kryo serializer for Storm](http://storm.incubator.apache.org/documentation/Serialization.html). + * Unfortunately we have not figured out a way to implement a parameterized `AvroKryoDecorator[T]` variant yet. + (A "straight-forward" approach we tried -- similar to the other parameterized components -- compiled fine but + failed at runtime when running the tests). Code contributions are welcome! +* Kafka and Storm integration + * [AvroKafkaSinkBolt[T]](src/main/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBolt.scala): + An `AvroKafkaSinkBolt[T <: org.apache.avro.specific.SpecificRecordBase]` that can be parameterized with the type + of the Avro record `T` it will serialize its data to before sending the encoded data to Kafka (i.e. no + need to write another Kafka sink bolt just because the bolt needs to handle a different Avro schema). + * Storm topologies that read Avro-encoded data from Kafka: + [KafkaStormDemo](src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala) and + [KafkaStormSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala) + * A Storm topology that writes Avro-encoded data to Kafka: + [KafkaStormSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala) +* Integration testing + * [KafkaSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala): + Tests for Kafka, which launch and run against in-memory instances of Kafka and ZooKeeper. + * [StormSpec](src/test/scala/com/miguno/kafkastorm/integration/StormSpec.scala): + Tests for Storm, which launch and run against in-memory instances of Storm and ZooKeeper. + * [KafkaStormSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala): + Tests for integrating Storm and Kafka, which launch and run against in-memory instances of Kafka, Storm, and + ZooKeeper. + + + + +# Implementation details + +* We use [Twitter Bijection](https://github.com/twitter/bijection) for Avro encoding and decoding. +* We use [Twitter Chill](https://github.com/twitter/chill/) (which in turn uses Bijection) to implement a + [custom Kryo serializer for Storm](src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala) that + handles our Avro-derived Java class `Tweet` from [twitter.avsc](src/main/avro/twitter.avsc). +* Unit and integration tests are implemented with [ScalaTest](http://scalatest.org/). +* We use [ZooKeeper 3.3.4](https://zookeeper.apache.org/) instead of the latest version 3.4.5. + See section _Known issues_ below for why we do that. +* We use the Kafka spout [wurstmeister/storm-kafka-0.8-plus](https://github.com/wurstmeister/storm-kafka-0.8-plus). + Unfortunately that spout is not yet released for Scala 2.10. For that reason [@miguno](https://github.com/miguno/) + has [forked and branched](https://github.com/miguno/storm-kafka-0.8-plus/tree/miguno_clojars) the code to add Scala + 2.10 support, and released such a version to [Clojars](https://clojars.org/com.miguno/storm-kafka-0.8-plus_2.10). + See [build.sbt](build.sbt) for details. + * _Once Storm 0.9.2 is released we will migrate to the new_ + _[Kafka spout that ships with Storm](https://github.com/apache/incubator-storm/tree/master/external/storm-kafka)_ + _(which is based on the spout developed by wurstmeister)._ + + + + +# Development + + + + +## Git setup: git-flow + +This project follows the [git-flow](https://github.com/nvie/gitflow) approach. This means, for instance, that: + +* The branch `develop` is used for integration of the "next release". +* The branch `master` is used for bringing forth production releases. + +See [git-flow](https://github.com/nvie/gitflow) and the introduction article +[Why aren't you using git-flow?](http://jeffkreeftmeijer.com/2010/why-arent-you-using-git-flow/) for details. + + + + +## Build requirements + +* [Scala](http://www.scala-lang.org/) 2.10.4 +* [sbt](http://www.scala-sbt.org/) 0.13.2 +* Oracle Java JDK 6 (version 6 is still recommended for use with Kafka and Storm) + * The code _in this project_ works with Java 7, too. However, some dependencies we use are not published for Java 7 + yet. + + +### Install on Mac OS X + +_The instructions below assume you have [Homebrew](http://brew.sh/) installed on your Mac._ + +First, install Oracle Java JDK 6 for Mac: + +* [Java 6 for Mac OS X](http://support.apple.com/downloads/DL1572/en_US/JavaForOSX2013-05.dmg) aka + "Java for OS X 2013-005". This will give you Java 1.6.0_65. + +Then, install Scala and sbt: + + $ brew update + $ brew install scala210 sbt + + +### Install on RHEL/CentOS 6 + +First, install Oracle Java JDK 6: + +* Follow [these instructions](http://www.if-not-true-then-false.com/2010/install-sun-oracle-java-jdk-jre-6-on-fedora-centos-red-hat-rhel/) + (untested!). + * Note: As a RHEL 6 user you may have access to a ready-to-use RPM package of Oracle JDK 6 in your existing yum + repositories as part of the RedHat Network (RHN). If so, you do not need to follow the instructions in the link + above. Instead, you only need to run e.g. `sudo yum install java-1.6.0-sun-devel` + ([details](https://access.redhat.com/site/documentation/en-US/JBoss_Enterprise_Application_Platform/5/html/Installation_Guide/appe-install_jdk.html)). + +Then, install Scala and sbt: + + $ sudo yum install http://www.scala-lang.org/files/archive/scala-2.10.4.rpm + $ sudo yum install http://dl.bintray.com/sbt/rpm/sbt-0.13.2.rpm + + +See [Download Scala 2.10.4](http://www.scala-lang.org/download/2.10.4.html) and +[Installing sbt](http://www.scala-sbt.org/release/docs/Getting-Started/Setup.html) for details. + + +### Install on Ubuntu/Debian + +First, install Oracle JDK 6: + +* Follow [these instructions](http://linuxg.net/how-to-install-oracle-java-jdk-678-on-ubuntu-13-04-12-10-12-04/) + (untested!). Note that by following these instructions you will install Oracle JDK/JRE from a third-party PPA package + repository (`ppa:webupd8team/java`, managed by [webupd8](http://www.webupd8.org/)). Unfortunately Oracle does not + provide official apt repositories for Ubuntu, and the Ubuntu team was required to remove "their" Oracle JDK/JRE + packages from the Ubuntu repositories because of licensing issues with Oracle. + +Then, install Scala and sbt: + + $ wget http://www.scala-lang.org/files/archive/scala-2.10.4.deb + $ sudo dpkg -i scala-2.10.4.deb + $ wget http://dl.bintray.com/sbt/debian/sbt-0.13.2.deb + $ sudo dpkg -i http://dl.bintray.com/sbt/debian/sbt-0.13.2.deb + +See [Download Scala 2.10.4](http://www.scala-lang.org/download/2.10.4.html) and +[Installing sbt](http://www.scala-sbt.org/release/docs/Getting-Started/Setup.html) for details. + + + + +## Building the code + + $ ./sbt clean compile + +If you want to only (re)generate Java classes from Avro schemas: + + $ ./sbt avro:generate + +Generated Java sources are stored under `target/scala-*/src_managed/main/compiled_avro/`. + + + + +## Running the tests + + $ ./sbt clean test + +Here are some examples that demonstrate how you can run only a certain subset of tests: + + # Use `-l` to exclude tests by tag: + # Run all tests WITH THE EXCEPTION of those tagged as integration tests + $ ./sbt "test-only * -- -l com.miguno.kafkastorm.integration.IntegrationTest" + + # Use `-n` to include tests by tag (and skip all tests that lack the tag): + # Run ONLY tests tagged as integration tests + $ ./sbt "test-only * -- -n com.miguno.kafkastorm.integration.IntegrationTest" + + # Run only the tests in suite AvroSchemeSpec: + $ ./sbt "test-only com.miguno.kafkastorm.storm.AvroSchemeSpec" + + # You can also combine the examples above, of course. + +Test reports in JUnit XML format are written to `target/test-reports/junitxml/*.xml`. Make sure that your actual build +steps run the `./sbt test` task, otherwise the JUnit XML reports will not be generate (note that `./sbt scoverage:test` +_will not_ generate the JUnit XML reports unfortunately). + +Integration with CI servers: + +* Jenkins integration: + * Configure the build job. + * Go to _Post-build Actions_. + * Add a post-build action for _Publish JUnit test result report_. + * In the _Test report XMLs_ field add the pattern `**/target/test-reports/junitxml/*.xml`. + * Now each build of your job will have a _Test Result_ link. +* TeamCity integration: + * Edit the build configuration. + * Select configuration step 3, _Build steps_. + * Under _Additional Build Features_ add a new build feature. + * Use the following build feature configuration: + * Report type: Ant JUnit + * Monitoring rules: `target/test-reports/junitxml/*.xml` + * Now each build of your job will have a _Tests_ tab. + +Further details are available at: + +* How to tag tests in ScalaTest: [Tagging your tests](http://www.scalatest.org/user_guide/tagging_your_tests) +* How to selectively run tests: [Using ScalaTest with sbt](http://www.scalatest.org/user_guide/using_scalatest_with_sbt) + and [How to Run Tagged Scala Tests with SBT and ScalaTest](http://code.hootsuite.com/tagged-tests-with-sbt/) + + + + +## Creating code coverage reports + +We are using [sbt-scoverage](https://github.com/scoverage/sbt-scoverage) to create code coverage reports for unit tests. + +Run the unit tests via: + + $ ./sbt clean scoverage:test + +* An HTML report will be created at `target/scala-2.10/scoverage-report/index.html`. +* An XML report will be created at `./target/scala-2.10/scoverage-report/scoverage.xml`. + +Integration with CI servers: + +* Jenkins integration: + * Configure the build. + * Go to _Post-build Actions_. + * Add a post-build action for _Publish Cobertura Coverage Report_. + * In the _Cobertura xml report pattern_ field add the pattern `**/target/scala-2.10/scoverage-report/scoverage.xml`. + * Now each build of your job will have a _Coverage Report_ link. +* TeamCity integration: + * Edit the build configuration. + * Select configuration step 1, _General settings_. + * In the _Artifact Paths_ field add the path `target/scala-2.10/scoverage-report/** => coberturareport/`. + * Now each build of your job will have a _Cobertura Coverage Report_ tab. + + + + +## Packaging the code + +To create a normal ("slim") jar: + + $ ./sbt clean package + + >>> Generates `target/scala-2.10/kafka-storm-starter_2.10-0.1.0-SNAPSHOT.jar` + +To create a fat jar, which includes any dependencies of kafka-storm-starter: + + $ ./sbt assembly + + >>> Generates `target/scala-2.10/kafka-storm-starter-assembly-0.1.0-SNAPSHOT.jar` + +To create a scaladoc/javadoc jar: + + $ ./sbt packageDoc + + >>> Generates `target/scala-2.10/kafka-storm-starter_2.10-0.1.0-SNAPSHOT-javadoc.jar` + +To create a sources jar: + + $ ./sbt packageSrc + + >>> Generates `target/scala-2.10/kafka-storm-starter_2.10-0.1.0-SNAPSHOT-sources.jar` + +To create API docs: + + $ ./sbt doc + + >>> Generates `target/scala-2.10/api/*` (HTML files) + + + + +## IDE support + +### IntelliJ IDEA + +kafka-storm-starter integrates the [sbt-idea plugin](https://github.com/mpeltonen/sbt-idea). Use the following command +to build IDEA project files: + + $ ./sbt gen-idea + +You can then open kafka-storm-starter as a project in IDEA via _File > Open..._ and selecting the top-level directory +of kafka-storm-starter. + +**Important note:** There is a bug when using the sbt plugins for Avro and for IntelliJ IDEA in combination. The sbt +plugin for Avro reads the Avro `*.avsc` schemas stored under `src/main/avro` and generates the corresponding Java +classes, which it stores under `target/scala-2.10/src_managed/main/compiled_avro` (in the case of kafka-storm-starter, +a `Tweet.java` class will be generated from the Avro schema [twitter.avsc](src/main/avro/twitter.avsc)). The latter +path must be added to IDEA's _Source Folders_ setting, which will happen automatically for you. However the +aforementioned bug will add a second, incorrect path to _Source Folders_, too, which will cause IDEA to complain about +not being able to find the Avro-generated Java classes (here: the `Tweet` class). + +Until this bug is fixed upstream you can use the following workaround, which you must perform everytime you run +`./sbt gen-idea`: + +1. In IntelliJ IDEA open the project structure for kafka-storm-starter via _File > Project Structure..._. +2. Under _Project settings_ on the left-hand side select _Modules_. +3. Select the _Sources_ tab on the right-hand side. +4. Remove the problematic `target/scala-2.10/src_managed/main/compiled_avro/com` entry from the _Source Folders_ listing + (the source folders are colored in light-blue). Note the trailing `.../com`, which comes from + `com.miguno.avro.Tweet` in the [twitter.avsc](src/main/avro/twitter.avsc) Avro schema. +5. Click Ok. + +See also this screenshot (click to enlarge): + +[![Fix bug in IntelliJIDEA when using avro Avro](images/IntelliJ-IDEA-Avro-bug_400x216.png?raw=true)](images/IntelliJ-IDEA-Avro-bug.png?raw=true) + + +### Eclipse + +kafka-storm-starter integrates the [sbt-eclipse plugin](https://github.com/typesafehub/sbteclipse). Use the following +command to build Eclipse project files: + + $ ./sbt eclipse + +Then use the _Import Wizard_ in Eclipse to import _Existing Projects into Workspace_. + + + + +# FAQ + + + + +## Kafka + +### Where do the unit tests store broker logs in the local filesystem? + +The in-memory Kafka instances that are launched by the unit tests store their Kafka "log" files (i.e. the files that +contain the messages that are being sent to the Kafka topics) under `/tmp/kafka-logs/`. + +You may need to manually remove this directory in case you want start from a clean state. At the moment the unit tests +do not remove this directory for you. + +### ZooKeeper exceptions "KeeperException: NoNode for /[ZK path]" logged at INFO level + +In short you can normally safely ignore those errors -- it's for a reason they are logged at INFO level and not at ERROR +level. + +As described in the mailing list thread [Zookeeper exceptions](http://mail-archives.apache.org/mod_mbox/incubator-kafka-users/201204.mbox/%3CCAFbh0Q3BxaAkyBq1_yUHhUkkhxX4RBQZPAA2pkR4U9+m4VY8nA@mail.gmail.com%3E): + +"The reason you see those NoNode error code is the following. Every time we want to create a new [ZK] path, say +`/brokers/ids/1`, we try to create it directly. If this fails because the parent path doesn't exist, we try to create +the parent path first. This will happen recursively. However, the `NoNode` error should show up only once, not every +time a broker is started (assuming ZK data hasn't been cleaned up)." + +A similar answer was given in the thread +[Clean up kafka environment](http://grokbase.com/t/kafka/users/137qgfyga0/clean-up-kafka-environmet): + +"These info messages show up when Kafka tries to create new consumer groups. While trying to create the children of +`/consumers/[group]`, if the parent path doesn't exist, the zookeeper server logs these messages. Kafka internally +handles these cases correctly by first creating the parent node." + + + + +## Storm + +### Storm `LocalCluster` and ZooKeeper + +[LocalCluster](https://github.com/apache/incubator-storm/blob/master/storm-core/src/clj/backtype/storm/LocalCluster.clj) +starts an embedded ZooKeeper instance listening at `localhost:2000/tcp`. If a different process is already bound to +`2000/tcp`, then Storm will increment the embedded ZooKeeper's port until it finds a free port (`2000` -> `2001` -> +`2002`, and so on). `LocalCluster` then reads the Storm defaults and overrides some of Storm's configuration (see the +`mk-local-storm-cluster` function in +[testing.clj](https://github.com/apache/incubator-storm/blob/master/storm-core/src/clj/backtype/storm/testing.clj) and +the `mk-inprocess-zookeeper` function in +[zookeeper.clj](https://github.com/apache/incubator-storm/blob/master/storm-core/src/clj/backtype/storm/zookeeper.clj) +for details): + + STORM-CLUSTER-MODE "local" + STORM-ZOOKEEPER-PORT zk-port + STORM-ZOOKEEPER-SERVERS ["localhost"]} + +where `zk-port` is the final port chosen. + +As of May 2014 it is not possible to launch a local Storm cluster via `LocalCluster` without its own embedded ZooKeeper. +Likewise it is not possible to control on which port the embedded ZooKeeper process will listen -- it will always follow +the `2000/tcp` based algorithm above to set the port. A JIRA ticket was opened to untangle this hard wiring between +`LocalCluster` and ZooKeeper, cf. +[STORM-213: Decouple In-Process ZooKeeper from LocalCluster](https://issues.apache.org/jira/browse/STORM-213). + + + + +# Known issues and limitations + +This section lists known issues and limitations a) for the upstream projects such as Storm and Kafka, and b) for our +own code. + + + + +## Upstream code + +### Kryo version conflict in Storm + +_Note: This problem is resolved in the upcoming 0.9.2 version of Storm._ + +There is a Kryo version conflict between Storm 0.9.1 (uses Kryo 2.17) and Twitter Chill (uses Kryo 2.21). + +In this code project we use the workaround to exclude Kryo (2.21) from the Twitter Chill dependency, but this may not +be a universal workaround. Twitter have apparently run into data corruption issues with Kryo 2.17, and for that reason +have built their own version of Storm using Kryo 2.21. +See [CHILL-173: Kryo version conflict between Chill and Storm 0.9.1-incubating causes Avro serialization to fail](https://github.com/twitter/chill/issues/173) +for details. + + +### ZooKeeper throws InstanceAlreadyExistsException during tests + +You will see the following exception when running the integration tests, which you can safely ignore: + + [2014-03-07 11:56:59,250] WARN Failed to register with JMX (org.apache.zookeeper.server.ZooKeeperServer) + javax.management.InstanceAlreadyExistsException: org.apache.ZooKeeperService:name0=StandaloneServer_port-1 + +The root cause is that in-memory ZooKeeper instances have a hardcoded JMX setup. And because we cannot prevent Storm's +`LocalCluster` to start its own ZooKeeper instance alongside "ours" (see FAQ section above), there will be two ZK +instances trying to use the same JMX setup. Since the JMX setup is not relevant for our testing the exception can be +safely ignored, albeit we'd prefer to come up with a proper fix, of course. + + +### ZooKeeper version 3.3.x recommended for use with Storm 0.9.1 and Kafka 0.8.x + +_Note: The upcoming version 0.9.2 of Storm uses ZooKeeper 3.4.5._ + +At the time of writing both Storm (<= 0.9.1) and Kafka (<= 0.8.1.1) are not officially compatible with ZooKeeper 3.4.x +yet, which is the latest stable version of ZooKeeper. Instead the use of ZooKeeper 3.3.x is recommended. + +So which version of ZooKeeper should you do pick, particularly if you are already running a ZooKeeper cluster for other +parts of your infrastructure (such as an Hadoop cluster)? + +**The TL;DR version is:** Try using ZooKeeper 3.4.5 for both Kafka and Storm, but see the caveats and workarounds +below. If you do run into problems, consider downgrading to ZooKeeper 3.3.6. If that fails, too, try 3.3.4. In the +worst case use separate ZooKeeper clusters/versions for Storm (3.3.3) and Kafka (3.3.4). + +**The longer version is:** Storm versions up to and including 0.9.1 want ZK 3.3.3, but the upcoming 0.9.2 version +relies on ZooKeeper 3.4.x. +[All current versions of Kafka still prefer ZK 3.3.4](https://kafka.apache.org/documentation.html#zkversion). +Generally speaking though, the best 3.3.x version of ZooKeeper is 3.3.6, which is the latest stable 3.3.x version. This +is because 3.3.6 fixed a number of serious bugs that could lead to data corruption. + +_Tip: You can verify against which ZK version the code in this project is actually built by running_ +_`./sbt dependency-graph`._ + +**The really long version is:** In the _code and tests_ of this project we cannot use ZK 3.4.x just yet because Storm +0.9.1 is not 100% incompatible with ZK 3.4.x. For instance, Storm will throw errors if you try to run a Storm +`LocalCluster` (for unit testing) against ZK 3.4.x. At the same time, and somewhat surprisingly, you can run a "real" +Storm cluster against ZK 3.4.x. For instance, Netflix have reportedly been using ZK 3.4.5 in production since some +time. + +* Storm and ZooKeeper: Storm versions up to and including 0.9.1 are built against ZooKeeper 3.3.3 because of Storm's + dependency on [Netflix Curator 1.0.1](https://github.com/Netflix/curator). These versions of Zookeeper and Curator + are very old, and the upcoming Storm 0.9.2 therefore switches to Apache Curator 2.4.0 with ZooKeeper 3.4.x. +* Kafka and ZooKeeper: LinkedIn recommend the use of ZK 3.3.x but warn against the use of 3.3.3 because that + version has known serious issues regarding ephemeral node deletion and session expirations. For these reasons + LinkedIn run ZK 3.3.4 in production. + See [ZooKeeper version](https://kafka.apache.org/documentation.html#zkversion) in the Kafka documentation. + Lastly, there is an open Kafka JIRA ticket that covers upgrading Kafka to ZK 3.4.5, see + [KAFKA-854: Upgrade dependencies for 0.8](https://issues.apache.org/jira/browse/KAFKA-854). +* Storm and Cloudera CDH 4.5: + * [Storm cannot run in combination with a recent Hadoop/HBase version](http://mail-archives.apache.org/mod_mbox/storm-user/201402.mbox/%3CCADoiZqom8Wuzi9uiqT4d01cTNn2r_nOmXyZyCSqEko-vOyrQBA@mail.gmail.com%3E) + -- The author ran into problems when using Storm in combination with Cloudera CDH 4. It looks as if he is trying + to build a code project that lists both Storm and Hadoop/HBase as its dependencies (similar to how we combine + Storm with Kafka), and due to that runs into ZooKeeper version conflicts as CDH 4 runs ZooKeeper 3.4.5. +* If in a production environment you run into problems when using ZooKeeper 3.4.5 with Storm <= 0.9.1, you can try + a [workaround using Google jarjar](https://groups.google.com/forum/#!topic/storm-user/TVVF_jqvD_A) in order to + deploy ZooKeeper 3.4.5 alongside Storm's/Curator's hard dependency on ZooKeeper 3.3.3. + [Another user reported](http://grokbase.com/t/gg/storm-user/134f2tw5gx/recommended-zookeeper-version-for-storm-0-8-2) + that he uses ZK 3.4.5 in production and ZK 3.3.3 for local testing by not including ZooKeeper in the uber jar + and putting the correct ZK version in the CLASSPATH at runtime. + [STORM-70: Use ZooKeeper 3.4.5](https://issues.apache.org/jira/browse/STORM-70). + + + + +## kafka-storm-starter code + +* Some code in kafka-storm-starter does not look like idiomatic Scala code. While sometimes this may be our own fault, + there is one area where we cannot easily prevent this from happening: When the underlying Java APIs (here: the Java + API of Storm) do not lend themselves to a more Scala-like code style. You can see this, for instance, in the way we + wire the spouts and bolts of a topology. One alternative, of course, would be to create Scala-fied wrappers but this + seemed inappropriate for this project. +* We are using `Thread.sleep()` in some tests instead of more intelligent approaches. To prevent transient failures we + may thus want to improve those tests. In Kafka's test suites, for instance, tests are using `waitUntilTrue()` to + detect more reliably when to proceed (or fail/timeout) with the next step. See the related discussion in the + [review request 19696 for KAFKA-1317](https://reviews.apache.org/r/19696/#comment71202). +* We noticed that the tests may fail when using Oracle/Sun JDK 1.6.0_24. Later versions (e.g. 1.6.0_31) work fine. + + + + +# Contributing to kafka-storm-starter + +Code contributions, bug reports, feature requests etc. are all welcome. + +If you are new to GitHub please read [Contributing to a project](https://help.github.com/articles/fork-a-repo) for how +to send patches and pull requests to kafka-storm-starter. + + + + +# License + +Copyright © 2014 Michael G. Noll + +See [LICENSE](LICENSE) for licensing information. + + + + +# References + + + + +## Wirbelsturm + +Want to perform 1-click deployments of Kafka clusters and/or Storm clusters (with a Graphite instance, with Redis, +with...)? Take a look at [Wirbelsturm](https://github.com/miguno/wirbelsturm), with which you can deploy such +environments locally and to Amazon AWS. + + + + +## Kafka + +Unit testing: + +* [buildlackey/cep/kafka-0.8.x](https://github.com/buildlackey/cep/tree/master/kafka-0.8.x) + -- A simple Kafka producer/consumer example with in-memory Kafka and Zookeeper instances. For a number of reasons + we opted not to use that code. We list it in this section in case someone else may find it helpful. + + + + +## Storm + +Storm in general: + +* [Storm FAQ](http://storm.incubator.apache.org/documentation/FAQ.html) +* [Config (Java API)](http://storm.incubator.apache.org/apidocs/backtype/storm/Config.html) +* [Understanding the Internal Message Buffers of Storm](http://www.michael-noll.com/blog/2013/06/21/understanding-storm-internal-message-buffers/) +* [Sending Metrics From Storm to Graphite](http://www.michael-noll.com/blog/2013/11/06/sending-metrics-from-storm-to-graphite/) + +Unit testing: + +* [TestingApiDemo.java](https://github.com/xumingming/storm-lib/blob/master/src/jvm/storm/TestingApiDemo.java) + -- Demonstrates in Java how to use Storm's built-in testing API. Unfortunately the code is more than a year old and + not well documented. + * Note that `backtype.storm.Testing` is apparently not well suited to test Trident topologies. + See [Any Java example to write test cases for storm Transactional topology](https://groups.google.com/forum/#!msg/storm-user/nZs2NwNqqn8/CjKaZK7eRFsJ) + (Mar 2013) for details. +* [MockOutputCollector](https://gist.github.com/k2xl/1782187) + -- Code example on how to implement a mock `OutputCollector` for unit testing. +* [Testing the logic of Storm topologies](https://groups.google.com/forum/#!topic/storm-user/Magc5-vt2Hg) + -- Discussion in the old storm-user mailing list, Dec 2011 +* [buildlackey/cep/storm-kafka](https://github.com/buildlackey/cep/tree/master/storm%2Bkafka) + -- Kafka spout integration test with an in-memory Storm cluster (`LocalCluster`), and in-memory Kafka and Zookeeper + instances. For a number of reasons we opted not to use that code. We list it in this section in case someone else + may find it helpful. +* [buildlackey/cep/esper+storm+kafka](https://github.com/buildlackey/cep/tree/master/esper%2Bstorm%2Bkafka) + -- Example illustrating a Kafka consumer spout, a Kafka producer bolt, and an Esper streaming query bolt +* [schleyfox/storm-test](https://github.com/schleyfox/storm-test) + -- Test utilities for Storm (in Clojure). + +Kafka spout [wurstmeister/storm-kafka-0.8-plus](https://github.com/wurstmeister/storm-kafka-0.8-plus): + +* [Example code on how to use the spout](https://github.com/wurstmeister/storm-kafka-0.8-plus-test) + +Kafka spout [HolmesNL/kafka-spout](https://github.com/HolmesNL/kafka-spout), written by the +[Netherlands Forensics Institute](http://forensicinstitute.nl): + +* [Main documentation](https://github.com/HolmesNL/kafka-spout/wiki) +* [KafkaSpout.java](https://github.com/HolmesNL/kafka-spout/blob/develop/src/main/java/nl/minvenj/nfi/storm/kafka/KafkaSpout.java) + -- Helpful to understand how the spout works. +* [ConfigUtils.java](https://github.com/HolmesNL/kafka-spout/blob/develop/src/main/java/nl/minvenj/nfi/storm/kafka/util/ConfigUtils.java) + -- Helpful to understand how the Kafka spout can be configured. + + + + +## Avro + +Twitter Bijection: + +* [SpecificAvroCodecsSpecification.scala](https://github.com/twitter/bijection/blob/develop/bijection-avro/src/test/scala/com/twitter/bijection/avro/SpecificAvroCodecsSpecification.scala) + -- How to use Bijection for Avro's `Specific*` API (which is what you would usually do) +* [GenericAvroCodecsSpecification.scala](https://github.com/twitter/bijection/blob/develop/bijection-avro/src/test/scala/com/twitter/bijection/avro/GenericAvroCodecsSpecification.scala) + -- How to use Bijection for Avro's `Generic*` API + +Kafka: + +* [How to use Kafka and Avro](http://stackoverflow.com/questions/8298308/how-to-use-kafka-and-avro) + + + + +## Kryo + +* [AdamKryoRegistrator.java](https://github.com/bigdatagenomics/adam/blob/master/adam-core/src/main/scala/edu/berkeley/cs/amplab/adam/serialization/AdamKryoRegistrator.scala) + -- example on how to register serializers with Kyro +* Twitter Chill examples on how to create Avro-based serializers for Kryo: + * [AvroSerializerSpec.scala](https://github.com/twitter/chill/blob/develop/chill-avro/src/test/scala/com/twitter/chill/avro/AvroSerializerSpec.scala) + * [BijectionEnrichedKryo.scala](https://github.com/twitter/chill/blob/develop/chill-bijection/src/main/scala/com/twitter/chill/BijectionEnrichedKryo.scala) diff --git a/assembly.sbt b/assembly.sbt new file mode 100644 index 0000000..fd69c77 --- /dev/null +++ b/assembly.sbt @@ -0,0 +1,16 @@ +import AssemblyKeys._ + +assemblySettings + +// Any customized settings must be written here, i.e. after 'assemblySettings' above. +// See https://github.com/sbt/sbt-assembly for available parameters. + +// Include "provided" dependencies back to run/test tasks' classpath. +// See: +// https://github.com/sbt/sbt-assembly#-provided-configuration +// http://stackoverflow.com/a/21803413/3827 +// +// In our case, the Storm dependency must be set to "provided (cf. `build.sbt`) because, when deploying and launching +// our Storm topology code "for real" to a distributed Storm cluster, Storm wants us to exclude the Storm dependencies +// (jars) as they are provided [no pun intended] by the Storm cluster. +run in Compile <<= Defaults.runTask(fullClasspath in Compile, mainClass in (Compile, run), runner in (Compile, run)) diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..2a3bb86 --- /dev/null +++ b/build.sbt @@ -0,0 +1,87 @@ +organization := "com.miguno.kafkastorm" + +name := "kafka-storm-starter" + +scalaVersion := "2.10.4" + +seq(sbtavro.SbtAvro.avroSettings : _*) + +// Configure the desired Avro version. sbt-avro automatically injects a libraryDependency. +(version in avroConfig) := "1.7.6" + +// Look for *.avsc etc. files in src/test/avro +(sourceDirectory in avroConfig) <<= (sourceDirectory in Compile)(_ / "avro") + +(stringType in avroConfig) := "String" + +// https://github.com/jrudolph/sbt-dependency-graph +net.virtualvoid.sbt.graph.Plugin.graphSettings + +resolvers ++= Seq( + "typesafe-repository" at "http://repo.typesafe.com/typesafe/releases/", + "clojars-repository" at "https://clojars.org/repo", + // For retrieving Kafka release artifacts directly from Apache. The artifacts are also available via Maven Central. + "Apache releases" at "https://repository.apache.org/content/repositories/releases/" +) + +libraryDependencies ++= Seq( + "com.twitter" %% "bijection-core" % "0.6.2", + "com.twitter" %% "bijection-avro" % "0.6.2", + // Chill uses Kryo 2.21, which is not fully compatible with 2.17 (used by Storm). + // We must exclude the newer Kryo version, otherwise we run into the problem described at + // https://github.com/thinkaurelius/titan/issues/301. + // + // TODO: Once Storm 0.9.2 is released we can update our dependencies to use Chill as-is (without excludes) because + // Storm then uses Kryo 2.21 (via Carbonite 1.3.3) just like Chill does. + "com.twitter" %% "chill" % "0.3.6" + exclude("com.esotericsoftware.kryo", "kryo"), + "com.twitter" % "chill-avro" % "0.3.6" + exclude("com.esotericsoftware.kryo", "kryo"), + "com.twitter" %% "chill-bijection" % "0.3.6" + exclude("com.esotericsoftware.kryo", "kryo"), + // The excludes of jms, jmxtools and jmxri are required as per https://issues.apache.org/jira/browse/KAFKA-974. + // The exclude of slf4j-simple is because it overlaps with our use of logback with slf4j facade; without the exclude + // we get slf4j warnings and logback's configuration is not picked up. + "org.apache.kafka" % "kafka_2.10" % "0.8.1.1" + exclude("javax.jms", "jms") + exclude("com.sun.jdmk", "jmxtools") + exclude("com.sun.jmx", "jmxri") + exclude("org.slf4j", "slf4j-simple"), + "org.apache.storm" % "storm-core" % "0.9.1-incubating" % "provided" + exclude("org.slf4j", "log4j-over-slf4j"), + // We exclude curator-framework because storm-kafka-0.8-plus recently switched from curator 1.0.1 to 1.3.3, which + // pulls in a newer version of ZooKeeper with which Storm 0.9.1 is not yet compatible. + // + // TODO: Remove the exclude once Storm 0.9.2 is released, because that version depends on a newer version (3.4.x) of + // ZooKeeper. + "com.miguno" %% "storm-kafka-0.8-plus" % "0.5.0-SNAPSHOT" + exclude("com.netflix.curator", "curator-framework"), + "com.netflix.curator" % "curator-test" % "1.0.1", + "com.101tec" % "zkclient" % "0.4", + // Logback with slf4j facade + "ch.qos.logback" % "logback-classic" % "1.1.2", + "ch.qos.logback" % "logback-core" % "1.1.2", + "org.slf4j" % "slf4j-api" % "1.7.7", + // Test dependencies + "org.scalatest" %% "scalatest" % "2.1.5" % "test", + "org.mockito" % "mockito-all" % "1.9.5" % "test" +) + +// Required IntelliJ workaround. This tells `sbt gen-idea` to include scala-reflect as a compile dependency (and not +// merely as a test dependency), which we need for TypeTag usage. +libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) + +publishArtifact in Test := false + +parallelExecution in Test := false + +// Write test results to file in JUnit XML format +testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-u", "target/test-reports/junitxml") + +// Write test results to console/stdout +testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-o") + +// See https://github.com/scoverage/scalac-scoverage-plugin +ScoverageSbtPlugin.instrumentSettings + +mainClass in (Compile,run) := Some("com.miguno.kafkastorm.storm.KafkaStormDemo") diff --git a/images/IntelliJ-IDEA-Avro-bug.png b/images/IntelliJ-IDEA-Avro-bug.png new file mode 100644 index 0000000000000000000000000000000000000000..455bb094d1f034c79199b9cd35ea573bc1f39725 GIT binary patch literal 115820 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
    oui+w~5RE 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 Knc_F*f&T~pop0O# literal 0 HcmV?d00001 diff --git a/images/IntelliJ-IDEA-Avro-bug_400x216.png b/images/IntelliJ-IDEA-Avro-bug_400x216.png new file mode 100644 index 0000000000000000000000000000000000000000..b5de7ef066b6b34d84ea9a025760c48bc1191511 GIT binary patch literal 50156 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 B+lBxD literal 0 HcmV?d00001 diff --git a/project/assembly.sbt b/project/assembly.sbt new file mode 100644 index 0000000..54c3252 --- /dev/null +++ b/project/assembly.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.11.2") diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..8ac605a --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.2 diff --git a/project/build.sbt b/project/build.sbt new file mode 100644 index 0000000..ccfbb29 --- /dev/null +++ b/project/build.sbt @@ -0,0 +1,2 @@ +// https://github.com/sbt/sbt-release +addSbtPlugin("com.github.gseitz" % "sbt-release" % "0.8.3") diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..a0fcddd --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,20 @@ +resolvers ++= Seq( + "sbt-plugin-releases-repo" at "http://repo.scala-sbt.org/scalasbt/sbt-plugin-releases", + "sbt-idea-repository" at "http://mpeltonen.github.io/maven/" +) + +// https://github.com/mpeltonen/sbt-idea +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.6.0") + +// https://github.com/typesafehub/sbteclipse +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.5.0") + +// https://github.com/cavorite/sbt-avro +addSbtPlugin("com.cavorite" % "sbt-avro" % "0.3.2") + +// https://github.com/jrudolph/sbt-dependency-graph +addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.7.4") + +// See https://github.com/scoverage/scalac-scoverage-plugin +// and https://github.com/scoverage/sbt-scoverage +addSbtPlugin("org.scoverage" %% "sbt-scoverage" % "0.98.2") diff --git a/sbt b/sbt new file mode 100755 index 0000000..15a6f76 --- /dev/null +++ b/sbt @@ -0,0 +1,457 @@ +#!/usr/bin/env bash +# +# A more capable sbt runner, coincidentally also called sbt. +# Author: Paul Phillips + +# todo - make this dynamic +declare -r sbt_release_version="0.13.2" +declare -r sbt_unreleased_version="0.13.5-RC1" +declare -r buildProps="project/build.properties" + +declare sbt_jar sbt_dir sbt_create sbt_version +declare scala_version java_home sbt_explicit_version +declare verbose noshare batch trace_level log_level +declare sbt_saved_stty + +echoerr () { echo >&2 "$@"; } +vlog () { [[ -n "$verbose" ]] && echoerr "$@"; } + +# spaces are possible, e.g. sbt.version = 0.13.0 +build_props_sbt () { + [[ -r "$buildProps" ]] && \ + grep '^sbt\.version' "$buildProps" | tr '=' ' ' | awk '{ print $2; }' +} + +update_build_props_sbt () { + local ver="$1" + local old="$(build_props_sbt)" + + [[ -r "$buildProps" ]] && [[ "$ver" != "$old" ]] && { + perl -pi -e "s/^sbt\.version\b.*\$/sbt.version=${ver}/" "$buildProps" + grep -q '^sbt.version[ =]' "$buildProps" || printf "\nsbt.version=%s\n" "$ver" >> "$buildProps" + + vlog "!!!" + vlog "!!! Updated file $buildProps setting sbt.version to: $ver" + vlog "!!! Previous value was: $old" + vlog "!!!" + } +} + +set_sbt_version () { + sbt_version="${sbt_explicit_version:-$(build_props_sbt)}" + [[ -n "$sbt_version" ]] || sbt_version=$sbt_release_version + export sbt_version +} + +# restore stty settings (echo in particular) +onSbtRunnerExit() { + [[ -n "$sbt_saved_stty" ]] || return + vlog "" + vlog "restoring stty: $sbt_saved_stty" + stty "$sbt_saved_stty" + unset sbt_saved_stty +} + +# save stty and trap exit, to ensure echo is reenabled if we are interrupted. +trap onSbtRunnerExit EXIT +sbt_saved_stty="$(stty -g 2>/dev/null)" +vlog "Saved stty: $sbt_saved_stty" + +# this seems to cover the bases on OSX, and someone will +# have to tell me about the others. +get_script_path () { + local path="$1" + [[ -L "$path" ]] || { echo "$path" ; return; } + + local target="$(readlink "$path")" + if [[ "${target:0:1}" == "/" ]]; then + echo "$target" + else + echo "${path%/*}/$target" + fi +} + +die() { + echo "Aborting: $@" + exit 1 +} + +make_url () { + version="$1" + + case "$version" in + 0.7.*) echo "http://simple-build-tool.googlecode.com/files/sbt-launch-0.7.7.jar" ;; + 0.10.* ) echo "$sbt_launch_repo/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; + 0.11.[12]) echo "$sbt_launch_repo/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; + *) echo "$sbt_launch_repo/org.scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; + esac +} + +init_default_option_file () { + local overriding_var="${!1}" + local default_file="$2" + if [[ ! -r "$default_file" && "$overriding_var" =~ ^@(.*)$ ]]; then + local envvar_file="${BASH_REMATCH[1]}" + if [[ -r "$envvar_file" ]]; then + default_file="$envvar_file" + fi + fi + echo "$default_file" +} + +declare -r cms_opts="-XX:+CMSClassUnloadingEnabled -XX:+UseConcMarkSweepGC" +declare -r jit_opts="-XX:ReservedCodeCacheSize=256m -XX:+TieredCompilation" +declare -r default_jvm_opts="-XX:MaxPermSize=384m -Xms512m -Xmx1536m -Xss2m $jit_opts $cms_opts" +declare -r noshare_opts="-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy" +declare -r latest_28="2.8.2" +declare -r latest_29="2.9.3" +declare -r latest_210="2.10.4" +declare -r latest_211="2.11.0" + +declare -r script_path="$(get_script_path "$BASH_SOURCE")" +declare -r script_name="${script_path##*/}" + +# some non-read-onlies set with defaults +declare java_cmd="java" +declare sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" +declare jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" +declare sbt_launch_repo="http://typesafe.artifactoryonline.com/typesafe/ivy-releases" + +# pull -J and -D options to give to java. +declare -a residual_args +declare -a java_args +declare -a scalac_args +declare -a sbt_commands + +# args to jvm/sbt via files or environment variables +declare -a extra_jvm_opts extra_sbt_opts + +# if set, use JAVA_HOME over java found in path +[[ -e "$JAVA_HOME/bin/java" ]] && java_cmd="$JAVA_HOME/bin/java" + +# directory to store sbt launchers +declare sbt_launch_dir="$HOME/.sbt/launchers" +[[ -d "$sbt_launch_dir" ]] || mkdir -p "$sbt_launch_dir" +[[ -w "$sbt_launch_dir" ]] || sbt_launch_dir="$(mktemp -d -t sbt_extras_launchers.XXXXXX)" + +build_props_scala () { + if [[ -r "$buildProps" ]]; then + versionLine="$(grep '^build.scala.versions' "$buildProps")" + versionString="${versionLine##build.scala.versions=}" + echo "${versionString%% .*}" + fi +} + +execRunner () { + # print the arguments one to a line, quoting any containing spaces + vlog "# Executing command line:" && { + for arg; do + if [[ -n "$arg" ]]; then + if printf "%s\n" "$arg" | grep -q ' '; then + printf >&2 "\"%s\"\n" "$arg" + else + printf >&2 "%s\n" "$arg" + fi + fi + done + vlog "" + } + + if [[ -n "$batch" ]]; then + exec /dev/null; then + curl --fail --silent "$url" --output "$jar" + elif which wget >/dev/null; then + wget --quiet -O "$jar" "$url" + fi + } && [[ -r "$jar" ]] +} + +acquire_sbt_jar () { + sbt_url="$(jar_url "$sbt_version")" + sbt_jar="$(jar_file "$sbt_version")" + + [[ -r "$sbt_jar" ]] || download_url "$sbt_url" "$sbt_jar" +} + +usage () { + cat < display stack traces with a max of frames (default: -1, traces suppressed) + -no-colors disable ANSI color codes + -sbt-create start sbt even if current directory contains no sbt project + -sbt-dir path to global settings/plugins directory (default: ~/.sbt/) + -sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11+) + -ivy path to local Ivy repository (default: ~/.ivy2) + -no-share use all local caches; no sharing + -offline put sbt in offline mode + -jvm-debug Turn on JVM debugging, open at the given port. + -batch Disable interactive mode + -prompt Set the sbt prompt; in expr, 's' is the State and 'e' is Extracted + + # sbt version (default: sbt.version from $buildProps if present, otherwise $sbt_release_version) + -sbt-version use the specified version of sbt (default: $sbt_release_version) + -sbt-jar use the specified jar as the sbt launcher + -sbt-launch-dir directory to hold sbt launchers (default: ~/.sbt/launchers) + -sbt-launch-repo repo url for downloading sbt launcher jar (default: $sbt_launch_repo) + + # scala version (default: as chosen by sbt) + -28 use $latest_28 + -29 use $latest_29 + -210 use $latest_210 + -211 use $latest_211 + -scala-home use the scala build at the specified directory + -scala-version use the specified version of scala + -binary-version use the specified scala version when searching for dependencies + + # java version (default: java from PATH, currently $(java -version 2>&1 | grep version)) + -java-home alternate JAVA_HOME + + # passing options to the jvm - note it does NOT use JAVA_OPTS due to pollution + # The default set is used if JVM_OPTS is unset and no -jvm-opts file is found + $default_jvm_opts + JVM_OPTS environment variable holding either the jvm args directly, or + the reference to a file containing jvm args if given path is prepended by '@' (e.g. '@/etc/jvmopts') + Note: "@"-file is overridden by local '.jvmopts' or '-jvm-opts' argument. + -jvm-opts file containing jvm args (if not given, .jvmopts in project root is used if present) + -Dkey=val pass -Dkey=val directly to the jvm + -J-X pass option -X directly to the jvm (-J is stripped) + + # passing options to sbt, OR to this runner + SBT_OPTS environment variable holding either the sbt args directly, or + the reference to a file containing sbt args if given path is prepended by '@' (e.g. '@/etc/sbtopts') + Note: "@"-file is overridden by local '.sbtopts' or '-sbt-opts' argument. + -sbt-opts file containing sbt args (if not given, .sbtopts in project root is used if present) + -S-X add -X to sbt's scalacOptions (-S is stripped) +EOM +} + +addJava () { + vlog "[addJava] arg = '$1'" + java_args=( "${java_args[@]}" "$1" ) +} +addSbt () { + vlog "[addSbt] arg = '$1'" + sbt_commands=( "${sbt_commands[@]}" "$1" ) +} +addScalac () { + vlog "[addScalac] arg = '$1'" + scalac_args=( "${scalac_args[@]}" "$1" ) +} +addResidual () { + vlog "[residual] arg = '$1'" + residual_args=( "${residual_args[@]}" "$1" ) +} +addResolver () { + addSbt "set resolvers += $1" +} +addDebugger () { + addJava "-Xdebug" + addJava "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=$1" +} +setScalaVersion () { + [[ "$1" == *"-SNAPSHOT" ]] && addResolver 'Resolver.sonatypeRepo("snapshots")' + addSbt "++ $1" +} + +process_args () +{ + require_arg () { + local type="$1" + local opt="$2" + local arg="$3" + + if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then + die "$opt requires <$type> argument" + fi + } + while [[ $# -gt 0 ]]; do + case "$1" in + -h|-help) usage; exit 1 ;; + -v) verbose=true && shift ;; + -d) addSbt "--debug" && shift ;; + -w) addSbt "--warn" && shift ;; + -q) addSbt "--error" && shift ;; + -trace) require_arg integer "$1" "$2" && trace_level="$2" && shift 2 ;; + -ivy) require_arg path "$1" "$2" && addJava "-Dsbt.ivy.home=$2" && shift 2 ;; + -no-colors) addJava "-Dsbt.log.noformat=true" && shift ;; + -no-share) noshare=true && shift ;; + -sbt-boot) require_arg path "$1" "$2" && addJava "-Dsbt.boot.directory=$2" && shift 2 ;; + -sbt-dir) require_arg path "$1" "$2" && sbt_dir="$2" && shift 2 ;; + -debug-inc) addJava "-Dxsbt.inc.debug=true" && shift ;; + -offline) addSbt "set offline := true" && shift ;; + -jvm-debug) require_arg port "$1" "$2" && addDebugger "$2" && shift 2 ;; + -batch) batch=true && shift ;; + -prompt) require_arg "expr" "$1" "$2" && addSbt "set shellPrompt in ThisBuild := (s => { val e = Project.extract(s) ; $2 })" && shift 2 ;; + + -sbt-create) sbt_create=true && shift ;; + -sbt-jar) require_arg path "$1" "$2" && sbt_jar="$2" && shift 2 ;; + -sbt-version) require_arg version "$1" "$2" && sbt_explicit_version="$2" && shift 2 ;; + -sbt-dev) sbt_explicit_version="$sbt_unreleased_version" && shift ;; +-sbt-launch-dir) require_arg path "$1" "$2" && sbt_launch_dir="$2" && shift 2 ;; +-sbt-launch-repo) require_arg path "$1" "$2" && sbt_launch_repo="$2" && shift 2 ;; + -scala-version) require_arg version "$1" "$2" && setScalaVersion "$2" && shift 2 ;; +-binary-version) require_arg version "$1" "$2" && addSbt "set scalaBinaryVersion in ThisBuild := \"$2\"" && shift 2 ;; + -scala-home) require_arg path "$1" "$2" && addSbt "set every scalaHome := Some(file(\"$2\"))" && shift 2 ;; + -java-home) require_arg path "$1" "$2" && java_cmd="$2/bin/java" && shift 2 ;; + -sbt-opts) require_arg path "$1" "$2" && sbt_opts_file="$2" && shift 2 ;; + -jvm-opts) require_arg path "$1" "$2" && jvm_opts_file="$2" && shift 2 ;; + + -D*) addJava "$1" && shift ;; + -J*) addJava "${1:2}" && shift ;; + -S*) addScalac "${1:2}" && shift ;; + -28) setScalaVersion "$latest_28" && shift ;; + -29) setScalaVersion "$latest_29" && shift ;; + -210) setScalaVersion "$latest_210" && shift ;; + -211) setScalaVersion "$latest_211" && shift ;; + + *) addResidual "$1" && shift ;; + esac + done +} + +# process the direct command line arguments +process_args "$@" + +# skip #-styled comments and blank lines +readConfigFile() { + while read line; do + [[ $line =~ ^# ]] || [[ -z $line ]] || echo "$line" + done < "$1" +} + +# if there are file/environment sbt_opts, process again so we +# can supply args to this runner +if [[ -r "$sbt_opts_file" ]]; then + vlog "Using sbt options defined in file $sbt_opts_file" + while read opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") +elif [[ -n "$SBT_OPTS" && ! ("$SBT_OPTS" =~ ^@.*) ]]; then + vlog "Using sbt options defined in variable \$SBT_OPTS" + extra_sbt_opts=( $SBT_OPTS ) +else + vlog "No extra sbt options have been defined" +fi + +[[ -n "${extra_sbt_opts[*]}" ]] && process_args "${extra_sbt_opts[@]}" + +# reset "$@" to the residual args +set -- "${residual_args[@]}" +argumentCount=$# + +# set sbt version +set_sbt_version + +# only exists in 0.12+ +setTraceLevel() { + case "$sbt_version" in + "0.7."* | "0.10."* | "0.11."* ) echoerr "Cannot set trace level in sbt version $sbt_version" ;; + *) addSbt "set every traceLevel := $trace_level" ;; + esac +} + +# set scalacOptions if we were given any -S opts +[[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[@]}\"" + +# Update build.properties on disk to set explicit version - sbt gives us no choice +[[ -n "$sbt_explicit_version" ]] && update_build_props_sbt "$sbt_explicit_version" +vlog "Detected sbt version $sbt_version" + +[[ -n "$scala_version" ]] && vlog "Overriding scala version to $scala_version" + +# no args - alert them there's stuff in here +(( argumentCount > 0 )) || { + vlog "Starting $script_name: invoke with -help for other options" + residual_args=( shell ) +} + +# verify this is an sbt dir or -create was given +[[ -r ./build.sbt || -d ./project || -n "$sbt_create" ]] || { + cat < Unit) { + val topicCountMap = Map(topic -> numThreads) + val valueDecoder = new DefaultDecoder + val keyDecoder = valueDecoder + val consumerMap = consumerConnector.createMessageStreams(topicCountMap, keyDecoder, valueDecoder) + val consumerThreads = consumerMap.get(topic) match { + case Some(streams) => streams.view.zipWithIndex map { + case (stream, threadId) => + new ConsumerTask(stream, new ConsumerTaskContext(threadId), f) + } + case _ => Seq() + } + consumerThreads foreach executor.submit + } + + def shutdown() { + consumerConnector.shutdown() + executor.shutdown() + } + + Runtime.getRuntime.addShutdownHook(new Thread() { + override def run() { + shutdown() + } + }) + +} + +class ConsumerTask[K, V, C <: ConsumerTaskContext](stream: KafkaStream[K, V], context: C, + f: (MessageAndMetadata[K, V], C) => Unit) extends Runnable with Logging { + + override def run() { + info(s"Consumer thread ${context.threadId} started") + stream foreach { + case msg: MessageAndMetadata[_, _] => + trace(s"Thread ${context.threadId} received message: " + msg) + f(msg, context) + case _ => trace(s"Received unexpected message type from broker") + } + info(s"Shutting down consumer thread ${context.threadId}") + } + +} + +case class ConsumerTaskContext(threadId: Int) \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/kafka/KafkaEmbedded.scala b/src/main/scala/com/miguno/kafkastorm/kafka/KafkaEmbedded.scala new file mode 100644 index 0000000..734e5c4 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/kafka/KafkaEmbedded.scala @@ -0,0 +1,68 @@ +package com.miguno.kafkastorm.kafka + +import java.util.Properties +import kafka.server.{KafkaServerStartable, KafkaConfig} +import kafka.utils.Logging + +/** + * Runs an in-memory, "embedded" instance of a Kafka broker, which listens at `127.0.0.1:9092`. + * + * Requires a running ZooKeeper instance to connect to. By default, it expects a ZooKeeper instance running at + * `127.0.0.1:2181`. You can specify a different ZooKeeper instance by setting the `zookeeper.connect` parameter in the + * broker's configuration. + * + * @param config Broker configuration settings. + */ +class KafkaEmbedded(config: Properties = new Properties) extends Logging { + + private val defaultZkConnect = "127.0.0.1:2181" + + private val effectiveConfig = { + val c = new Properties + c.load(this.getClass.getResourceAsStream("/broker-defaults.properties")) + c.putAll(config) + c + } + + private val kafkaConfig = new KafkaConfig(effectiveConfig) + private val kafka = new KafkaServerStartable(kafkaConfig) + + /** + * This broker's `metadata.broker.list` value. Example: `127.0.0.1:9092`. + * + * You can use this to tell Kafka producers and consumers how to connect to this instance. + */ + val brokerList = kafka.serverConfig.hostName + ":" + kafka.serverConfig.port + + /** + * The ZooKeeper connection string aka `zookeeper.connect`. + */ + val zookeeperConnect = { + val zkConnectLookup = Option(effectiveConfig.getProperty("zookeeper.connect")) + zkConnectLookup match { + case Some(zkConnect) => zkConnect + case _ => + warn(s"zookeeper.connect is not configured -- falling back to default setting $defaultZkConnect") + defaultZkConnect + } + } + + /** + * Start the broker. + */ + def start() { + debug(s"Starting embedded Kafka broker at $brokerList (using ZooKeeper server at $zookeeperConnect) ...") + kafka.startup() + debug("Embedded Kafka broker startup completed") + } + + /** + * Stop the broker. + */ + def stop() { + debug("Shutting down embedded Kafka broker...") + kafka.shutdown() + debug("Embedded Kafka broker shutdown completed") + } + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/kafka/KafkaProducerApp.scala b/src/main/scala/com/miguno/kafkastorm/kafka/KafkaProducerApp.scala new file mode 100644 index 0000000..7b30db7 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/kafka/KafkaProducerApp.scala @@ -0,0 +1,77 @@ +package com.miguno.kafkastorm.kafka + +import kafka.producer.{KeyedMessage, ProducerConfig, Producer} +import java.util.Properties + +/** + * Demonstrates how to implement a simple Kafka producer application to send data to Kafka. + * + * Don't read too much into the actual implementation of this class. Its sole purpose is to showcase the use of the + * Kafka API. + * + * @param topic The Kafka topic to send data to. + * @param brokerList Value for Kafka's `metadata.broker.list` setting. + * @param producerConfig Additional producer configuration settings. + */ +case class KafkaProducerApp( + val topic: String, + val brokerList: String, + producerConfig: Properties = new Properties + ) { + + private val producer = { + val effectiveConfig = { + val c = new Properties + c.load(this.getClass.getResourceAsStream("/producer-defaults.properties")) + c.putAll(producerConfig) + c.put("metadata.broker.list", brokerList) + c + } + new Producer[Array[Byte], Array[Byte]](new ProducerConfig(effectiveConfig)) + } + + // The configuration field of the wrapped producer is immutable (including its nested fields), so it's safe to expose + // it directly. + val config = producer.config + + private def toMessage(key: Option[Array[Byte]], value: Array[Byte]): KeyedMessage[Array[Byte], Array[Byte]] = + key match { + case Some(key) => new KeyedMessage(topic, key, value) + case _ => new KeyedMessage(topic, value) + } + + def send(key: Array[Byte], value: Array[Byte]): Unit = producer.send(toMessage(Some(key), value)) + + def send(value: Array[Byte]): Unit = producer.send(toMessage(None, value)) + +} + +/** + * Creates KafkaProducerApp instances. + * + * We require such a factory because of how Storm and notably + * [[http://storm.incubator.apache.org/documentation/Serialization.html serialization within Storm]] work. + * Without such a factory we cannot properly unit tests Storm bolts that need to write to Kafka. + * + * Preferably we would simply pass a Kafka producer directly to a Storm bolt. During testing we could then mock this + * collaborator. However this intuitive approach fails at (Storm) runtime because Kafka producers are not serializable. + * The alternative approach of instantiating the Kafka producer from within the bolt (e.g. using a `@transient lazy val` + * field) does work at runtime but prevents us from verifying the correct interaction between our bolt's code and its + * collaborator, the Kafka producer, because we cannot easily mock the producer in this setup. The chosen approach of + * the factory method, while introducing some level of unwanted indirection and complexity, is a pragmatic approach to + * make our Storm code work correctly at runtime and to make it testable. + * + * @param topic The Kafka topic to send data to. + * @param brokerList Value for Kafka's `metadata.broker.list` setting. + * @param config Additional producer configuration settings. + */ +abstract class KafkaProducerAppFactory(topic: String, brokerList: String, config: Properties) extends Serializable { + def newInstance(): KafkaProducerApp +} + +class BaseKafkaProducerAppFactory(topic: String, brokerList: String, config: Properties = new Properties) + extends KafkaProducerAppFactory(topic, brokerList, config) { + + override def newInstance() = new KafkaProducerApp(topic, brokerList, config) + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/AvroDecoderBolt.scala b/src/main/scala/com/miguno/kafkastorm/storm/AvroDecoderBolt.scala new file mode 100644 index 0000000..5390e7a --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/AvroDecoderBolt.scala @@ -0,0 +1,106 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.topology.base.BaseBasicBolt +import backtype.storm.topology.{BasicOutputCollector, OutputFieldsDeclarer} +import backtype.storm.tuple.{Fields, Tuple, Values} +import com.google.common.base.Throwables +import com.twitter.bijection.avro.SpecificAvroCodecs +import com.twitter.bijection.Injection +import org.apache.avro.specific.SpecificRecordBase +import org.slf4j.{Logger, LoggerFactory} +import scala.util.{Try, Failure, Success} + +/** + * An binaryAvro->pojoAvro converter bolt. + * + * This bolt expects incoming data in Avro-encoded binary format, serialized according to the Avro schema of `T`. It + * will deserialize the incoming data into a `T` pojo, and emit this pojo to downstream consumers. As such this bolt + * can be considered the Storm equivalent of Twitter Bijection's `Injection.invert[T, Array[Byte]](bytes)` for + * Avro data. + * + * By using this bolt you don't need to write another decoder bolt just because the bolt needs to handle a different + * Avro schema. + * + * @example {{{ + * import backtype.storm.topology.TopologyBuilder + * import com.miguno.avro.Tweet + * + * val builder = new TopologyBuilder + * // ...spout is set up here... + * val decoderBolt = new AvroDecoderBolt[Tweet] + * builder.setBolt(decoderBoltId, decoderBolt).shuffleGrouping(spoutId) // or whatever grouping you need + * }}} + * + * @param inputField The name of the field in the input tuple to read from. (Default: "bytes") + * @param outputField The name of the field in the output tuple to write to. (Default: "pojo") + * @tparam T The type of the Avro record (e.g. a `Tweet`) based on the underlying Avro schema being used. Must be + * a subclass of Avro's `SpecificRecordBase`. + */ +class AvroDecoderBolt[T <: SpecificRecordBase : Manifest]( + inputField: String = "bytes", + outputField: String = "pojo") + extends BaseBasicBolt { + + // Note: Ideally we would like to use TypeTag's instead of Manifest's here. Doing so would only require replacing + // `manifest[T]` with `typeOf[T]`, and adding AvroDecoderBolt[T : TypeTag]. Unfortunately there is a known + // serialization bug in Scala's TypeTag implementation that will trigger runtime exceptions when submitting/running + // this class in a Storm topology. + // + // See "SI-5919: Type tags (and Exprs as well) should be serializable" (https://issues.scala-lang.org/browse/SI-5919) + val tpe = manifest[T] + + // Must be transient because Logger is not serializable + @transient lazy private val log: Logger = LoggerFactory.getLogger(classOf[AvroDecoderBolt[T]]) + + // Must be transient because Injection is not serializable. Must be implicit because that's who Injection works. + @transient lazy implicit private val specificAvroBinaryInjection: Injection[T, Array[Byte]] = + SpecificAvroCodecs.toBinary[T] + + override def execute(tuple: Tuple, collector: BasicOutputCollector) { + val readTry = Try(tuple.getBinaryByField(inputField)) + readTry match { + case Success(bytes) if bytes != null => decodeAndSinkToKafka(bytes, collector) + case Success(_) => log.error("Reading from input tuple returned null") + case Failure(e) => log.error("Could not read from input tuple: " + Throwables.getStackTraceAsString(e)) + } + } + + private def decodeAndSinkToKafka(bytes: Array[Byte], collector: BasicOutputCollector) { + require(bytes != null, "bytes must not be null") + val decodeTry = Injection.invert[T, Array[Byte]](bytes) + decodeTry match { + case Success(pojo) => + log.debug("Binary data decoded into pojo: " + pojo) + collector.emit(new Values(pojo)) + case Failure(e) => log.error("Could not decode binary data: " + Throwables.getStackTraceAsString(e)) + } + } + + override def declareOutputFields(declarer: OutputFieldsDeclarer) { + declarer.declare(new Fields(outputField)) + } + +} + +object AvroDecoderBolt { + + /** + * Factory method for Java interoperability. + * + * @example {{{ + * // in Java + * AvroDecoderBolt decoderBolt = AvroDecoderBolt.ofType(Tweet.class); + * }}} + * + * @param cls + * @tparam T + * @return + */ + def ofType[T <: SpecificRecordBase](cls: java.lang.Class[T]) = { + val manifest = Manifest.classType[T](cls) + newInstance[T](manifest) + } + + private def newInstance[T <: SpecificRecordBase : Manifest] = new AvroDecoderBolt[T] + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBolt.scala b/src/main/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBolt.scala new file mode 100644 index 0000000..7cd79d8 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBolt.scala @@ -0,0 +1,104 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.task.TopologyContext +import backtype.storm.topology.{BasicOutputCollector, OutputFieldsDeclarer} +import backtype.storm.topology.base.BaseBasicBolt +import backtype.storm.tuple.{Tuple, Fields} +import com.miguno.kafkastorm.kafka.{KafkaProducerAppFactory, KafkaProducerApp} +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import java.util +import org.apache.avro.specific.SpecificRecordBase +import org.slf4j.{Logger, LoggerFactory} + +/** + * A Storm->Kafka writer bolt. + * + * This bolt expects Avro pojos of type `T` as incoming data. It will Avro-encode these pojos into a binary + * representation (bytes) according to the Avro schema of `T`, and then send these bytes to Kafka. + * + * @param producerFactory A factory to instantiate the required Kafka producer. We require such a factory because of + * unit testing and the way Storm code is (shipped and) executed in a Storm cluster. Because + * a bolt is instantiated on a different JVM we cannot simply pass the "final" Kafka producer + * directly to the bolt when we wire the topology. Instead we must enable each bolt instance to + * create its own Kafka producer when it is starting up (and this startup typically happens in a + * different JVM on a different machine). + * @param inputField The name of the field in the input tuple to read from. (Default: "pojo") + * @param outputField The name of the field in the output tuple to write to. (Default: "bytes") + * @tparam T The type of the Avro record (e.g. a `Tweet`) based on the underlying Avro schema being used. Must be + * a subclass of Avro's `SpecificRecordBase`. + */ +class AvroKafkaSinkBolt[T <: SpecificRecordBase : Manifest]( + producerFactory: KafkaProducerAppFactory, + inputField: String = "pojo", + outputField: String = "bytes") + extends BaseBasicBolt { + + // Note: Ideally we would like to use TypeTag's instead of Manifest's here. Doing so would only require replacing + // `manifest[T]` with `typeOf[T]`, and adding AvroKafkaSinkBolt[T : TypeTag]. Unfortunately there is a known + // serialization bug in Scala's TypeTag implementation that will trigger runtime exceptions when submitting/running + // this class in a Storm topology. + // + // See "SI-5919: Type tags (and Exprs as well) should be serializable" (https://issues.scala-lang.org/browse/SI-5919) + val tpe = manifest[T] + + // Must be transient because Logger is not serializable + @transient lazy private val log: Logger = LoggerFactory.getLogger(classOf[AvroKafkaSinkBolt[T]]) + + // Must be transient because Injection is not serializable + @transient lazy implicit private val specificAvroBinaryInjection: Injection[T, Array[Byte]] = + SpecificAvroCodecs.toBinary[T] + + // Must be transient because KafkaProducerApp is not serializable. The factory approach to instantiate a Kafka producer + // unfortunately means we must use a var combined with `prepare()` -- a val would cause a NullPointerException at + // runtime for the producer. + @transient private var producer: KafkaProducerApp = _ + + override def prepare(stormConf: util.Map[_, _], context: TopologyContext) { + producer = producerFactory.newInstance() + } + + override def execute(tuple: Tuple, collector: BasicOutputCollector) { + tuple.getValueByField(inputField) match { + case pojo: T => + val bytes = Injection[T, Array[Byte]](pojo) + log.debug("Encoded pojo " + pojo + " to Avro binary format") + producer.send(bytes) + case _ => log.error("Could not decode binary data") + } + } + + override def declareOutputFields(declarer: OutputFieldsDeclarer) { + declarer.declare(new Fields()) + } + +} + +object AvroKafkaSinkBolt { + + /** + * Factory method for Java interoperability. + * + * @example {{{ + * // Java example + * AvroKafkaSinkBolt kafkaSinkBolt = AvroKafkaSinkBolt.ofType(Tweet.class)(brokerList, ...); + * }}} + * + * @param cls + * @tparam T + * @return + */ + def ofType[T <: SpecificRecordBase](cls: java.lang.Class[T])( + producerFactory: KafkaProducerAppFactory, + inputFieldName: String = "pojo") = { + val manifest = Manifest.classType[T](cls) + newInstance[T](producerFactory, inputFieldName)(manifest) + } + + private def newInstance[T <: SpecificRecordBase]( + producerFactory: KafkaProducerAppFactory, + inputFieldName: String = "pojo") + (implicit man: Manifest[T]) = + new AvroKafkaSinkBolt[T](producerFactory, inputFieldName) + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/AvroScheme.scala b/src/main/scala/com/miguno/kafkastorm/storm/AvroScheme.scala new file mode 100644 index 0000000..8a767e6 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/AvroScheme.scala @@ -0,0 +1,80 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.spout.Scheme +import backtype.storm.tuple.{Fields, Values} +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import org.apache.avro.specific.SpecificRecordBase +import scala.util.{Failure, Success} + +/** + * A custom binaryAvro->pojoAvro `backtype.storm.spout.Scheme` to auto-deserialize a spout's incoming data. You can + * parameterize this scheme with the Avro type `T` of the spout's expected input data. + * + * In the case of `storm.kafka.KafkaSpout` its default scheme is Storm's `backtype.storm.spout.RawMultiScheme`, + * which simply returns the raw bytes of the incoming data (i.e. leaving deserialization up to you in subsequent bolts + * such as [[AvroDecoderBolt]]). Alternatively, you configure the spout to use this custom scheme. If you do, then the + * spout will automatically deserialize its incoming data into pojos. Note that you will need to register a custom + * Kryo decorator for the Avro type `T`, see [[TweetAvroKryoDecorator]] for an example. + * + * @example {{{ + * import backtype.storm.spout.SchemeAsMultiScheme + * import com.miguno.avro.Tweet + * storm.kafka.{KafkaSpout, SpoutConfig} + * + * val spoutConfig = new SpoutConfig(...) + * spoutConfig.scheme = new SchemeAsMultiScheme(new AvroScheme[Tweet]) + * val kafkaSpout = new KafkaSpout(spoutConfig) + * }}} + * + * @tparam T The type of the Avro record (e.g. a `Tweet`) based on the underlying Avro schema being used. Must be + * a subclass of Avro's `SpecificRecordBase`. + */ +class AvroScheme[T <: SpecificRecordBase : Manifest] extends Scheme { + + // Note: Ideally we would like to use TypeTag's instead of Manifest's here. Doing so would only require replacing + // `manifest[T]` with `typeOf[T]`, and adding AvroScheme[T : TypeTag]. Unfortunately there is a known serialization + // bug in the TypeTag implementation of Scala versions <= 2.11.1 that will trigger runtime exceptions when + // submitting/running this class in a Storm topology. + // + // See "SI-5919: Type tags (and Exprs as well) should be serializable" (https://issues.scala-lang.org/browse/SI-5919) + val tpe = manifest[T] + + private val OutputFieldName = "pojo" + + @transient lazy implicit private val specificAvroBinaryInjection = SpecificAvroCodecs.toBinary[T] + + override def deserialize(bytes: Array[Byte]): java.util.List[AnyRef] = { + val result = Injection.invert[T, Array[Byte]](bytes) + result match { + case Success(pojo) => new Values(pojo) + case Failure(e) => throw new RuntimeException("Could not decode input bytes") + } + } + + override def getOutputFields() = new Fields(OutputFieldName) + +} + +object AvroScheme { + + /** + * Factory method for Java interoperability. + * + * @example {{{ + * // in Java + * AvroScheme avroScheme = AvroScheme.ofType(Tweet.class); + * }}} + * + * @param cls + * @tparam T + * @return + */ + def ofType[T <: SpecificRecordBase](cls: java.lang.Class[T]) = { + val manifest = Manifest.classType[T](cls) + newInstance[T](manifest) + } + + private def newInstance[T <: SpecificRecordBase : Manifest] = new AvroScheme[T] + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala b/src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala new file mode 100644 index 0000000..4a81ea9 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/KafkaStormDemo.scala @@ -0,0 +1,121 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.{Config, LocalCluster} +import backtype.storm.generated.KillOptions +import backtype.storm.topology.TopologyBuilder +import com.miguno.kafkastorm.kafka.KafkaEmbedded +import com.miguno.kafkastorm.zookeeper.ZooKeeperEmbedded +import java.util.Properties +import kafka.admin.AdminUtils +import kafka.utils.ZKStringSerializer +import org.I0Itec.zkclient.ZkClient +import scala.concurrent.duration._ +import storm.kafka.{KafkaSpout, SpoutConfig, ZkHosts} + +/** + * Showcases how to create a Storm topology that reads data from Kafka. Because it's a demo this topology does not + * (yet?) do anything to the input data -- it just reads, that's it. If you want to add functionality you only need to + * put one or more Storm bolts after the spout that reads from Kafka. + * + * The default setup runs the topology against an in-memory instance of Kafka (that is backed by an in-memory instance + * of ZooKeeper). Alternatively, you can also point the topology to a "real" Kafka cluster. An easy and quick way to + * deploy such a Kafka and ZooKeeper infrastructure is to use a tool such as + * [[https://github.com/miguno/wirbelsturm Wirbelsturm]]. + */ +class KafkaStormDemo(kafkaZkConnect: String, topic: String, numTopicPartitions: Int = 1, + topologyName: String = "kafka-storm-starter", runtime: Duration = 1.hour) { + + def runTopologyLocally() { + val zkHosts = new ZkHosts(kafkaZkConnect) + val topic = "testing" + val zkRoot = "/kafka-spout" + // The spout appends this id to zkRoot when composing its ZooKeeper path. You don't need a leading `/`. + val zkSpoutId = "kafka-storm-starter" + val kafkaConfig = new SpoutConfig(zkHosts, topic, zkRoot, zkSpoutId) + val kafkaSpout = new KafkaSpout(kafkaConfig) + val numSpoutExecutors = numTopicPartitions + val builder = new TopologyBuilder + val spoutId = "kafka-spout" + builder.setSpout(spoutId, kafkaSpout, numSpoutExecutors) + + // Showcases how to customize the topology configuration + val topologyConfiguration = { + val c = new Config + c.setDebug(false) + c.setNumWorkers(4) + c.setMaxSpoutPending(1000) + c.setMessageTimeoutSecs(60) + c.setNumAckers(0) + c.setMaxTaskParallelism(50) + c.put(Config.TOPOLOGY_EXECUTOR_RECEIVE_BUFFER_SIZE, 16384: Integer) + c.put(Config.TOPOLOGY_EXECUTOR_SEND_BUFFER_SIZE, 16384: Integer) + c.put(Config.TOPOLOGY_RECEIVER_BUFFER_SIZE, 8: Integer) + c.put(Config.TOPOLOGY_TRANSFER_BUFFER_SIZE, 32: Integer) + c.put(Config.TOPOLOGY_STATS_SAMPLE_RATE, 0.05: java.lang.Double) + c + } + + // Now run the topology in a local, in-memory Storm cluster + val cluster = new LocalCluster + cluster.submitTopology(topologyName, topologyConfiguration, builder.createTopology()) + Thread.sleep(runtime.toMillis) + val killOpts = new KillOptions() + killOpts.set_wait_secs(1) + cluster.killTopologyWithOpts(topologyName, killOpts) + cluster.shutdown() + } + +} + +object KafkaStormDemo { + + private var zookeeperEmbedded: Option[ZooKeeperEmbedded] = None + private var zkClient: Option[ZkClient] = None + private var kafkaEmbedded: Option[KafkaEmbedded] = None + + def main(args: Array[String]) { + val kafkaTopic = "testing" + startZooKeeperAndKafka(kafkaTopic) + for {z <- zookeeperEmbedded} { + val topology = new KafkaStormDemo(z.connectString, kafkaTopic) + topology.runTopologyLocally() + } + stopZooKeeperAndKafka() + } + + /** + * Launches in-memory, embedded instances of ZooKeeper and Kafka so that our demo Storm topology can connect to and + * read from Kafka. + */ + private def startZooKeeperAndKafka(topic: String, numTopicPartitions: Int = 1, numTopicReplicationFactor: Int = 1, + zookeeperPort: Int = 2181) { + + zookeeperEmbedded = Some(new ZooKeeperEmbedded(zookeeperPort)) + for {z <- zookeeperEmbedded} { + val brokerConfig = new Properties + brokerConfig.put("zookeeper.connect", z.connectString) + kafkaEmbedded = Some(new KafkaEmbedded(brokerConfig)) + for {k <- kafkaEmbedded} { + k.start() + } + + val sessionTimeout = 30.seconds + val connectionTimeout = 30.seconds + zkClient = Some(new ZkClient(z.connectString, sessionTimeout.toMillis.toInt, connectionTimeout.toMillis.toInt, + ZKStringSerializer)) + for { + zc <- zkClient + } { + val topicConfig = new Properties + AdminUtils.createTopic(zc, topic, numTopicPartitions, numTopicReplicationFactor, topicConfig) + } + } + } + + private def stopZooKeeperAndKafka() { + for {k <- kafkaEmbedded} k.stop() + for {zc <- zkClient} zc.close() + for {z <- zookeeperEmbedded} z.stop() + } + +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala b/src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala new file mode 100644 index 0000000..313a423 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/TweetAvroKryoDecorator.scala @@ -0,0 +1,14 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.serialization.IKryoDecorator +import com.esotericsoftware.kryo.Kryo +import com.miguno.avro.Tweet +import com.twitter.chill.KryoSerializer +import com.twitter.chill.avro.AvroSerializer + +class TweetAvroKryoDecorator extends IKryoDecorator { + override def decorate(k: Kryo) { + k.register(classOf[Tweet], AvroSerializer.SpecificRecordSerializer[Tweet]) + KryoSerializer.registerAll(k) + } +} \ No newline at end of file diff --git a/src/main/scala/com/miguno/kafkastorm/storm/utils/StormRunner.scala b/src/main/scala/com/miguno/kafkastorm/storm/utils/StormRunner.scala new file mode 100644 index 0000000..2a71e72 --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/storm/utils/StormRunner.scala @@ -0,0 +1,24 @@ +package com.miguno.kafkastorm.storm.utils + +import backtype.storm.{Config, StormSubmitter, LocalCluster} +import backtype.storm.generated.StormTopology +import scala.concurrent.duration._ + +/** + * Provides convenience functions to run Storm topologies locally and remotely (i.e. in a "real" Storm cluster). + */ +object StormRunner { + + def runTopologyLocally(topology: StormTopology, topologyName: String, conf: Config, runtime: Duration) { + val cluster: LocalCluster = new LocalCluster + cluster.submitTopology(topologyName, conf, topology) + Thread.sleep(runtime.toMillis) + cluster.killTopology(topologyName) + cluster.shutdown() + } + + def runTopologyRemotely(topology: StormTopology, topologyName: String, conf: Config) { + StormSubmitter.submitTopology(topologyName, conf, topology) + } + +} diff --git a/src/main/scala/com/miguno/kafkastorm/zookeeper/ZooKeeperEmbedded.scala b/src/main/scala/com/miguno/kafkastorm/zookeeper/ZooKeeperEmbedded.scala new file mode 100644 index 0000000..f7a9e7a --- /dev/null +++ b/src/main/scala/com/miguno/kafkastorm/zookeeper/ZooKeeperEmbedded.scala @@ -0,0 +1,36 @@ +package com.miguno.kafkastorm.zookeeper + +import com.netflix.curator.test.TestingServer +import kafka.utils.Logging + +/** + * Runs an in-memory, "embedded" instance of a ZooKeeper server. + * + * The ZooKeeper server instance is automatically started when you create a new instance of this class. + * + * @param port The port (aka `clientPort`) to listen to. Default: 2181. + */ +class ZooKeeperEmbedded(port: Int) extends Logging { + + debug(s"Starting embedded ZooKeeper server on port ${port}...") + + private val server = new TestingServer(port) + + /** + * Stop the instance. + */ + def stop() { + debug("Shutting down embedded ZooKeeper server...") + server.close() + debug("Embedded ZooKeeper server shutdown completed") + } + + /** + * The ZooKeeper connection string aka `zookeeper.connect` in `hostnameOrIp:port` format. + * Example: `127.0.0.1:2181`. + * + * You can use this to e.g. tell Kafka and Storm how to connect to this instance. + */ + val connectString = server.getConnectString + +} \ No newline at end of file diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties new file mode 100644 index 0000000..25ae243 --- /dev/null +++ b/src/test/resources/log4j.properties @@ -0,0 +1,87 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +kafka.logs.dir=logs + +log4j.rootLogger=WARN, stdout + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=[%d] %p %m (%c)%n + +log4j.appender.kafkaAppender=org.apache.log4j.DailyRollingFileAppender +log4j.appender.kafkaAppender.DatePattern='.'yyyy-MM-dd-HH +log4j.appender.kafkaAppender.File=${kafka.logs.dir}/server.log +log4j.appender.kafkaAppender.layout=org.apache.log4j.PatternLayout +log4j.appender.kafkaAppender.layout.ConversionPattern=[%d] %p %m (%c)%n + +log4j.appender.stateChangeAppender=org.apache.log4j.DailyRollingFileAppender +log4j.appender.stateChangeAppender.DatePattern='.'yyyy-MM-dd-HH +log4j.appender.stateChangeAppender.File=${kafka.logs.dir}/state-change.log +log4j.appender.stateChangeAppender.layout=org.apache.log4j.PatternLayout +log4j.appender.stateChangeAppender.layout.ConversionPattern=[%d] %p %m (%c)%n + +log4j.appender.requestAppender=org.apache.log4j.DailyRollingFileAppender +log4j.appender.requestAppender.DatePattern='.'yyyy-MM-dd-HH +log4j.appender.requestAppender.File=${kafka.logs.dir}/kafka-request.log +log4j.appender.requestAppender.layout=org.apache.log4j.PatternLayout +log4j.appender.requestAppender.layout.ConversionPattern=[%d] %p %m (%c)%n + +log4j.appender.cleanerAppender=org.apache.log4j.DailyRollingFileAppender +log4j.appender.cleanerAppender.DatePattern='.'yyyy-MM-dd-HH +log4j.appender.cleanerAppender.File=log-cleaner.log +log4j.appender.cleanerAppender.layout=org.apache.log4j.PatternLayout +log4j.appender.cleanerAppender.layout.ConversionPattern=[%d] %p %m (%c)%n + +log4j.appender.controllerAppender=org.apache.log4j.DailyRollingFileAppender +log4j.appender.controllerAppender.DatePattern='.'yyyy-MM-dd-HH +log4j.appender.controllerAppender.File=${kafka.logs.dir}/controller.log +log4j.appender.controllerAppender.layout=org.apache.log4j.PatternLayout +log4j.appender.controllerAppender.layout.ConversionPattern=[%d] %p %m (%c)%n + +# Turn on all our debugging info +#log4j.logger.kafka.producer.async.DefaultEventHandler=DEBUG, kafkaAppender +#log4j.logger.kafka.client.ClientUtils=DEBUG, kafkaAppender +#log4j.logger.kafka.perf=DEBUG, kafkaAppender +#log4j.logger.kafka.perf.ProducerPerformance$ProducerThread=DEBUG, kafkaAppender +#log4j.logger.org.I0Itec.zkclient.ZkClient=DEBUG +log4j.logger.kafka=WARN, kafkaAppender +# Set WARN to INFO to see e.g. effective Kafka broker/consumer/producer config properties (cf. VerifiableProperties) +log4j.logger.kafka.utils=WARN, kafkaAppender + +log4j.logger.kafka.network.RequestChannel$=WARN, requestAppender +log4j.additivity.kafka.network.RequestChannel$=false + +#log4j.logger.kafka.network.Processor=TRACE, requestAppender +#log4j.logger.kafka.server.KafkaApis=TRACE, requestAppender +#log4j.additivity.kafka.server.KafkaApis=false +log4j.logger.kafka.request.logger=WARN, requestAppender +log4j.additivity.kafka.request.logger=false + +log4j.logger.kafka.controller=TRACE, controllerAppender +log4j.additivity.kafka.controller=false + +log4j.logger.kafka.log.LogCleaner=WARN, cleanerAppender +log4j.additivity.kafka.log.LogCleaner=false +log4j.logger.kafka.log.Cleaner=WARN, cleanerAppender +log4j.additivity.kafka.log.Cleaner=false + +log4j.logger.state.change.logger=TRACE, stateChangeAppender +log4j.additivity.state.change.logger=false + +# kafka-storm-starter settings +log4j.logger.com.miguno.kafkastorm=DEBUG, stdout +# If additivity is not set to false you will see log messages for com.miguno.kafkastorm.* twice. +log4j.additivity.com.miguno.kafkastorm=false diff --git a/src/test/scala/com/miguno/kafkastorm/integration/IntegrationSuite.scala b/src/test/scala/com/miguno/kafkastorm/integration/IntegrationSuite.scala new file mode 100644 index 0000000..7e7b2b2 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/integration/IntegrationSuite.scala @@ -0,0 +1,9 @@ +package com.miguno.kafkastorm.integration + +import org.scalatest.Stepwise + +class IntegrationSuite extends Stepwise( + new KafkaSpec, + new StormSpec, + new KafkaStormSpec +) \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/integration/IntegrationTest.scala b/src/test/scala/com/miguno/kafkastorm/integration/IntegrationTest.scala new file mode 100644 index 0000000..c8c9ecf --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/integration/IntegrationTest.scala @@ -0,0 +1,5 @@ +package com.miguno.kafkastorm.integration + +import org.scalatest.Tag + +object IntegrationTest extends Tag("com.miguno.kafkastorm.integration.IntegrationTest") diff --git a/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala b/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala new file mode 100644 index 0000000..e58485a --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala @@ -0,0 +1,218 @@ +package com.miguno.kafkastorm.integration + +import _root_.kafka.message.MessageAndMetadata +import _root_.kafka.utils.{Logging, ZKStringSerializer} +import com.miguno.avro.Tweet +import com.miguno.kafkastorm.kafka.{KafkaProducerApp, ConsumerTaskContext, KafkaConsumer, KafkaEmbedded} +import com.miguno.kafkastorm.zookeeper.ZooKeeperEmbedded +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import java.util.Properties +import org.I0Itec.zkclient.ZkClient +import org.scalatest._ +import scala.collection.mutable +import scala.concurrent.duration._ +import kafka.admin.AdminUtils + +@DoNotDiscover +class KafkaSpec extends FunSpec with Matchers with BeforeAndAfterAll with GivenWhenThen with Logging { + + private val testTopic = "testing" + private val testTopicNumPartitions = 1 + private val testTopicReplicationFactor = 1 + private val zookeeperPort = 2181 + + private var zookeeperEmbedded: Option[ZooKeeperEmbedded] = None + private var zkClient: Option[ZkClient] = None + private var kafkaEmbedded: Option[KafkaEmbedded] = None + + implicit val specificAvroBinaryInjectionForTweet = SpecificAvroCodecs.toBinary[Tweet] + + override def beforeAll() { + // Start embedded ZooKeeper server + zookeeperEmbedded = Some(new ZooKeeperEmbedded(zookeeperPort)) + + for {z <- zookeeperEmbedded} { + // Start embedded Kafka broker + val brokerConfig = new Properties + brokerConfig.put("zookeeper.connect", z.connectString) + kafkaEmbedded = Some(new KafkaEmbedded(brokerConfig)) + for {k <- kafkaEmbedded} { + k.start() + } + + // Create test topic + val sessionTimeout = 30.seconds + val connectionTimeout = 30.seconds + zkClient = Some(new ZkClient(z.connectString, sessionTimeout.toMillis.toInt, connectionTimeout.toMillis.toInt, + ZKStringSerializer)) + for { + zc <- zkClient + } { + val topicConfig = new Properties + AdminUtils.createTopic(zc, testTopic, testTopicNumPartitions, testTopicReplicationFactor, topicConfig) + } + } + } + + override def afterAll() { + for {k <- kafkaEmbedded} k.stop() + + for { + zc <- zkClient + } { + info("ZooKeeper client: shutting down...") + zc.close() + info("ZooKeeper client: shutdown completed") + } + + for {z <- zookeeperEmbedded} z.stop() + } + + + val fixture = { + val BeginningOfEpoch = 0.seconds + val AnyTimestamp = 1234.seconds + val now = System.currentTimeMillis().millis + + new { + val t1 = new Tweet("ANY_USER_1", "ANY_TEXT_1", now.toSeconds) + val t2 = new Tweet("ANY_USER_2", "ANY_TEXT_2", BeginningOfEpoch.toSeconds) + val t3 = new Tweet("ANY_USER_3", "ANY_TEXT_3", AnyTimestamp.toSeconds) + + val messages = Seq(t1, t2, t3) + } + } + + describe("Kafka") { + + it("should synchronously send and receive a Tweet in Avro format", IntegrationTest) { + for { + z <- zookeeperEmbedded + k <- kafkaEmbedded + } { + Given("a ZooKeeper instance") + And("a Kafka broker instance") + And("some tweets") + val f = fixture + val tweets = f.messages + And("a single-threaded Kafka consumer group") + // The Kafka consumer group must be running before the first messages are being sent to the topic. + val numConsumerThreads = 1 + val consumerConfig = { + val c = new Properties + c.put("group.id", "test-consumer") + c + } + val consumer = new KafkaConsumer(testTopic, z.connectString, numConsumerThreads, consumerConfig) + val actualTweets = new mutable.SynchronizedQueue[Tweet] + consumer.startConsumers( + (m: MessageAndMetadata[Array[Byte], Array[Byte]], c: ConsumerTaskContext) => { + val tweet = Injection.invert[Tweet, Array[Byte]](m.message) + for {t <- tweet} { + info(s"Consumer thread ${c.threadId}: received Tweet ${t} from partition ${m.partition} of topic ${m.topic} (offset: ${m.offset})") + actualTweets += t + } + }) + val waitForConsumerStartup = 300.millis + debug(s"Waiting $waitForConsumerStartup ms for Kafka consumer threads to launch") + Thread.sleep(waitForConsumerStartup.toMillis) + debug("Finished waiting for Kafka consumer threads to launch") + + When("I start a synchronous Kafka producer that sends the tweets in Avro binary format") + val syncProducerConfig = { + val c = new Properties + c.put("producer.type", "sync") + c.put("client.id", "test-sync-producer") + c.put("request.required.acks", "1") + c + } + val producerApp = new KafkaProducerApp(testTopic, k.brokerList, syncProducerConfig) + tweets foreach { + case tweet => { + val bytes = Injection[Tweet, Array[Byte]](tweet) + info(s"Synchronously sending Tweet $tweet to topic ${producerApp.topic}") + producerApp.send(bytes) + } + } + + Then("the consumer app should receive the tweets") + val waitForConsumerToReadStormOutput = 300.millis + debug(s"Waiting $waitForConsumerToReadStormOutput ms for Kafka consumer threads to read messages") + Thread.sleep(waitForConsumerToReadStormOutput.toMillis) + debug("Finished waiting for Kafka consumer threads to read messages") + debug("Shutting down Kafka consumer threads") + consumer.shutdown() + + actualTweets.toSeq should be(f.messages.toSeq) + } + } + + it("should asynchronously send and receive a Tweet in Avro format", IntegrationTest) { + for { + z <- zookeeperEmbedded + k <- kafkaEmbedded + } { + Given("a ZooKeeper instance") + And("a Kafka broker instance") + And("some tweets") + val f = fixture + val tweets = f.messages + And("a single-threaded Kafka consumer group") + // The Kafka consumer group must be running before the first messages are being sent to the topic. + val numConsumerThreads = 1 + val consumerConfig = { + val c = new Properties + c.put("group.id", "test-consumer") + c + } + val consumer = new KafkaConsumer(testTopic, z.connectString, numConsumerThreads, consumerConfig) + val actualTweets = new mutable.SynchronizedQueue[Tweet] + consumer.startConsumers( + (m: MessageAndMetadata[Array[Byte], Array[Byte]], c: ConsumerTaskContext) => { + val tweet = Injection.invert[Tweet, Array[Byte]](m.message) + for {t <- tweet} { + info(s"Consumer thread ${c.threadId}: received Tweet ${t} from partition ${m.partition} of topic ${m.topic} (offset: ${m.offset})") + actualTweets += t + } + }) + val waitForConsumerStartup = 300.millis + debug(s"Waiting $waitForConsumerStartup ms for Kafka consumer threads to launch") + Thread.sleep(waitForConsumerStartup.toMillis) + debug("Finished waiting for Kafka consumer threads to launch") + + When("I start an asynchronous Kafka producer that sends the tweets in Avro binary format") + val syncProducerConfig = { + val c = new Properties + c.put("producer.type", "async") + c.put("client.id", "test-sync-producer") + c.put("request.required.acks", "1") + // We must set `batch.num.messages` and/or `queue.buffering.max.ms` so that the async producer will actually + // send our (typically few) test messages before the unit test finishes. + c.put("batch.num.messages", tweets.size.toString) + c + } + val producerApp = new KafkaProducerApp(testTopic, k.brokerList, syncProducerConfig) + tweets foreach { + case tweet => { + val bytes = Injection[Tweet, Array[Byte]](tweet) + info(s"Asynchronously sending Tweet $tweet to topic ${producerApp.topic}") + producerApp.send(bytes) + } + } + + Then("the consumer app should receive the tweets") + val waitForConsumerToReadStormOutput = 300.millis + debug(s"Waiting $waitForConsumerToReadStormOutput ms for Kafka consumer threads to read messages") + Thread.sleep(waitForConsumerToReadStormOutput.toMillis) + debug("Finished waiting for Kafka consumer threads to read messages") + debug("Shutting down Kafka consumer threads") + consumer.shutdown() + + actualTweets.toSeq should be(f.messages.toSeq) + } + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala b/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala new file mode 100644 index 0000000..ed2d8e7 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala @@ -0,0 +1,317 @@ +package com.miguno.kafkastorm.integration + +import kafka.admin.AdminUtils +import _root_.kafka.utils.{Logging, ZKStringSerializer} +import _root_.storm.kafka.{KafkaSpout, SpoutConfig, ZkHosts} +import backtype.storm.{Testing, ILocalCluster, Config} +import backtype.storm.generated.StormTopology +import backtype.storm.spout.SchemeAsMultiScheme +import backtype.storm.testing._ +import backtype.storm.topology.TopologyBuilder +import com.miguno.avro.Tweet +import com.miguno.kafkastorm.kafka._ +import com.miguno.kafkastorm.storm.{AvroDecoderBolt, AvroKafkaSinkBolt, AvroScheme, TweetAvroKryoDecorator} +import com.miguno.kafkastorm.zookeeper.ZooKeeperEmbedded +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import java.util.Properties +import kafka.message.MessageAndMetadata +import org.I0Itec.zkclient.ZkClient +import org.scalatest._ +import scala.collection.mutable +import scala.concurrent.duration._ + +/** + * This Kafka/Storm integration test code is slightly more complicated than the other tests in this project. This is + * due to a number of reasons, such as: the way Storm topologies are "wired" and configured, the test facilities + * exposed by Storm, and -- on a higher level -- because there are quite a number of components involved (ZooKeeper, + * Kafka producers and consumers, Storm) which must be set up, run, and terminated in the correct order. For these + * reasons the integration tests are not simple "given/when/then" style tests. + */ +@DoNotDiscover +class KafkaStormSpec extends FeatureSpec with Matchers with BeforeAndAfterAll with GivenWhenThen with Logging { + + private val inputTopic = "testing-input" + private val inputTopicNumPartitions = 1 + private val inputTopicReplicationFactor = 1 + private val outputTopic = "testing-output" + private val outputTopicNumPartitions = 1 + private val outputTopicReplicationFactor = 1 + private val zookeeperPort = 2181 + private var zookeeperEmbedded: Option[ZooKeeperEmbedded] = None + private var zkClient: Option[ZkClient] = None + private var kafkaEmbedded: Option[KafkaEmbedded] = None + + implicit val specificAvroBinaryInjectionForTweet = SpecificAvroCodecs.toBinary[Tweet] + + override def beforeAll() { + // Start embedded ZooKeeper server + zookeeperEmbedded = Some(new ZooKeeperEmbedded(zookeeperPort)) + + for {z <- zookeeperEmbedded} { + // Start embedded Kafka broker + val brokerConfig = new Properties + brokerConfig.put("zookeeper.connect", z.connectString) + kafkaEmbedded = Some(new KafkaEmbedded(brokerConfig)) + for {k <- kafkaEmbedded} { + k.start() + } + + // Create test topics + val sessionTimeout = 30.seconds + val connectionTimeout = 30.seconds + // Note: You must initialize the ZkClient with ZKStringSerializer. If you don't, then createTopic() will only + // seem to work (it will return without error). Topic will exist in only ZooKeeper, and will be returned when + // listing topics, but Kafka itself does not create the topic. + zkClient = Some(new ZkClient(z.connectString, sessionTimeout.toMillis.toInt, connectionTimeout.toMillis.toInt, + ZKStringSerializer)) + for { + zc <- zkClient + } { + val inputTopicConfig = new Properties + AdminUtils.createTopic(zc, inputTopic, inputTopicNumPartitions, inputTopicReplicationFactor, inputTopicConfig) + val outputTopicConfig = new Properties + AdminUtils.createTopic(zc, outputTopic, outputTopicNumPartitions, outputTopicReplicationFactor, + outputTopicConfig) + } + } + } + + override def afterAll() { + for {k <- kafkaEmbedded} k.stop() + + for { + zc <- zkClient + } { + info("ZooKeeper client: shutting down...") + zc.close() + info("ZooKeeper client: shutdown completed") + } + + for {z <- zookeeperEmbedded} z.stop() + } + + val fixture = { + val BeginningOfEpoch = 0.seconds + val AnyTimestamp = 1234.seconds + val now = System.currentTimeMillis().millis + + new { + val t1 = new Tweet("ANY_USER_1", "ANY_TEXT_1", now.toSeconds) + val t2 = new Tweet("ANY_USER_2", "ANY_TEXT_2", BeginningOfEpoch.toSeconds) + val t3 = new Tweet("ANY_USER_3", "ANY_TEXT_3", AnyTimestamp.toSeconds) + + val messages = Seq(t1, t2, t3) + } + } + + info("As a user of Storm") + info("I want to read Avro-encoded data from Kafka") + info("so that I can quickly build Kafka<->Storm data flows") + + feature("AvroDecoderBolt[T]") { + + scenario("User creates a Storm topology that uses AvroDecoderBolt", IntegrationTest) { + for { + k <- kafkaEmbedded + z <- zookeeperEmbedded + } { + Given("a ZooKeeper instance") + And("a Kafka broker instance") + And(s"a Storm topology that uses AvroDecoderBolt and that reads tweets from topic $inputTopic and writes " + + s"them as-is to topic $outputTopic") + // We create a topology instance that makes use of an Avro decoder bolt to deserialize the Kafka spout's output + // into pojos. Here, the data flow is KafkaSpout -> AvroDecoderBolt -> AvroKafkaSinkBolt. + val builder = new TopologyBuilder + val kafkaSpoutId = "kafka-spout" + val kafkaSpoutConfig = kafkaSpoutBaseConfig(z.connectString, inputTopic) + val kafkaSpout = new KafkaSpout(kafkaSpoutConfig) + val numSpoutExecutors = inputTopicNumPartitions + builder.setSpout(kafkaSpoutId, kafkaSpout, numSpoutExecutors) + + val decoderBoltId = "avro-decoder-bolt" + val decoderBolt = new AvroDecoderBolt[Tweet] + // Note: Should test messages arrive out-of-order, we may want to enforce a parallelism of 1 for this bolt. + builder.setBolt(decoderBoltId, decoderBolt).globalGrouping(kafkaSpoutId) + + val kafkaSinkBoltId = "avro-kafka-sink-bolt" + val producerAppFactory = new BaseKafkaProducerAppFactory(outputTopic, k.brokerList) + val kafkaSinkBolt = new AvroKafkaSinkBolt[Tweet](producerAppFactory) + // Note: Should test messages arrive out-of-order, we may want to enforce a parallelism of 1 for this bolt. + builder.setBolt(kafkaSinkBoltId, kafkaSinkBolt).globalGrouping(decoderBoltId) + val topology = builder.createTopology() + + baseIntegrationTest(z, k, topology, inputTopic, outputTopic) + } + } + } + + feature("AvroScheme[T] for Kafka spout") { + scenario("User creates a Storm topology that uses AvroScheme in Kafka spout", IntegrationTest) { + for { + k <- kafkaEmbedded + z <- zookeeperEmbedded + } { + Given("a ZooKeeper instance") + And("a Kafka broker instance") + And(s"a Storm topology that uses AvroScheme and that reads tweets from topic $inputTopic and writes them " + + s"as-is to topic $outputTopic") + // Creates a topology instance that adds an Avro decoder "scheme" to the Kafka spout, so that the spout's output + // are ready-to-use pojos. Here, the data flow is KafkaSpout -> AvroKafkaSinkBolt. + // + // Note that Storm will still need to re-serialize the spout's pojo output to send the data across the wire to + // downstream consumers/bolts, which will then deserialize the data again. In our case we have a custom Kryo + // serializer registered with Storm to make this serde step as fast as possible. + val builder = new TopologyBuilder + val kafkaSpoutId = "kafka-spout" + val kafkaSpoutConfig = kafkaSpoutBaseConfig(z.connectString, inputTopic) + // You can provide the Kafka spout with a custom `Scheme` to deserialize incoming messages in a particular way. + // The default scheme is Storm's `backtype.storm.spout.RawMultiScheme`, which simply returns the raw bytes of the + // incoming data (i.e. leaving deserialization up to you). In this example, we configure the spout to use + // a custom scheme, AvroScheme[Tweet], which will modify the spout to automatically deserialize incoming data + // into pojos. + kafkaSpoutConfig.scheme = new SchemeAsMultiScheme(new AvroScheme[Tweet]) + val kafkaSpout = new KafkaSpout(kafkaSpoutConfig) + val numSpoutExecutors = inputTopicNumPartitions + builder.setSpout(kafkaSpoutId, kafkaSpout, numSpoutExecutors) + + val kafkaSinkBoltId = "avro-kafka-sink-bolt" + val producerAppFactory = new BaseKafkaProducerAppFactory(outputTopic, k.brokerList) + val kafkaSinkBolt = new AvroKafkaSinkBolt[Tweet](producerAppFactory) + // Note: Should test messages arrive out-of-order, we may want to enforce a parallelism of 1 for this bolt. + builder.setBolt(kafkaSinkBoltId, kafkaSinkBolt).globalGrouping(kafkaSpoutId) + val topology = builder.createTopology() + + baseIntegrationTest(z, k, topology, inputTopic, outputTopic) + } + } + } + + private def kafkaSpoutBaseConfig(zookeeperConnect: String, inputTopic: String): SpoutConfig = { + val zkHosts = new ZkHosts(zookeeperConnect) + val zkRoot = "/kafka-storm-starter-spout" + // This id is appended to zkRoot for constructing a ZK path under which the spout stores partition information. + val zkId = "kafka-spout" + // To configure the spout to read from the very beginning of the topic (auto.offset.reset = smallest), you can use + // either of the following two equivalent approaches: + // + // 1. spoutConfig.startOffsetTime = kafka.api.OffsetRequest.EarliestTime + // 2. spoutConfig.forceFromStart = true + // + // To configure the spout to read from the end of the topic (auto.offset.reset = largest), you can use either of + // the following two equivalent approaches: + // + // 1. Do nothing -- reading from the end of the topic is the default behavior. + // 2. spoutConfig.startOffsetTime = kafka.api.OffsetRequest.LatestTime + // + val spoutConfig = new SpoutConfig(zkHosts, inputTopic, zkRoot, zkId) + spoutConfig + } + + /** + * This method sends Avro-encoded test data into a Kafka "input" topic. This data is read from Kafka into Storm, + * which will then decode and re-encode the data, and then write the data to an "output" topic in Kafka (which is our + * means/workaround to "tap into" Storm's output, as we haven't been able yet to use Storm's built-in testing + * facilities for such integration tests). Lastly, we read the data from the "output" topic via a Kafka consumer + * group, and then compare the output data with the input data, with the latter serving the dual purpose of also + * being the expected output data. + */ + private def baseIntegrationTest(zookeeper: ZooKeeperEmbedded, kafka: KafkaEmbedded, topology: StormTopology, + inputTopic: String, outputTopic: String) { + And("some tweets") + val f = fixture + val tweets = f.messages + + And(s"a synchronous Kafka producer app that writes to the topic $inputTopic") + val kafkaSyncProducerConfig = { + val c = new Properties + c.put("producer.type", "sync") + c.put("client.id", "kafka-storm-test-sync-producer") + c.put("request.required.acks", "1") + c + } + val producerApp = new KafkaProducerApp(inputTopic, kafka.brokerList, kafkaSyncProducerConfig) + + And(s"a single-threaded Kafka consumer app that reads from topic $outputTopic") + // We start the Kafka consumer group, which (in our case) must be running before the first messages are being sent + // to the output Kafka topic. The Storm topology will write its output to this topic. We use the Kafka consumer + // group to learn which data was created by Storm, and compare this actual output data to the expected data (which + // in our case is the original input data). + val numConsumerThreads = 1 + val kafkaConsumerConfig = { + val c = new Properties + c.put("group.id", "kafka-storm-test-consumer") + c + } + val consumer = new KafkaConsumer(outputTopic, zookeeper.connectString, numConsumerThreads, kafkaConsumerConfig) + val actualTweets = new mutable.SynchronizedQueue[Tweet] + consumer.startConsumers( + (m: MessageAndMetadata[Array[Byte], Array[Byte]], c: ConsumerTaskContext) => { + val tweet = Injection.invert[Tweet, Array[Byte]](m.message()) + for {t <- tweet} { + info(s"Consumer thread ${c.threadId}: received Tweet $t from partition ${m.partition} of topic ${m.topic} " + + s"(offset: ${m.offset})") + actualTweets += t + } + }) + val waitForConsumerStartup = 300.millis + Thread.sleep(waitForConsumerStartup.toMillis) + + And("a Storm topology configuration that registers an Avro Kryo decorator for Tweet") + // We create the topology configuration here simply to clarify that it is part of the test's initial context defined + // under "Given". + val topologyConfig = { + val conf = new Config + // Use more than one worker thread. It looks as if serialization occurs only if you have actual parallelism in + // LocalCluster (i.e. numWorkers > 1). + conf.setNumWorkers(2) + // Never use Java's default serialization. This allows us to see whether Kryo serialization is properly + // configured and working for all types. + conf.setFallBackOnJavaSerialization(false) + // Serialization config, see http://storm.incubator.apache.org/documentation/Serialization.html + // Note: We haven't been able yet to come up with a KryoDecorator[Tweet] approach. + conf.registerDecorator(classOf[TweetAvroKryoDecorator]) + conf + } + + When("I run the Storm topology") + val stormTestClusterParameters = { + val mkClusterParam = new MkClusterParam + mkClusterParam.setSupervisors(2) + val daemonConf = new Config + // STORM_LOCAL_MODE_ZMQ: Whether or not to use ZeroMQ for messaging in local mode. If this is set to false, then + // Storm will use a pure-Java messaging system. The purpose of this flag is to make it easy to run Storm in local + // mode by eliminating the need for native dependencies, which can be difficult to install. + daemonConf.put(Config.STORM_LOCAL_MODE_ZMQ, false: java.lang.Boolean) + mkClusterParam.setDaemonConf(daemonConf) + mkClusterParam + } + Testing.withLocalCluster(stormTestClusterParameters, new TestJob() { + override def run(stormCluster: ILocalCluster) { + val topologyName = "storm-kafka-integration-test" + stormCluster.submitTopology(topologyName, topologyConfig, topology) + val waitForTopologyStartupMs = 3.seconds.toMillis + Thread.sleep(waitForTopologyStartupMs) + + And("I use the Kafka producer app to Avro-encode the tweets and sent them to Kafka") + // Send the test input data to Kafka. + tweets foreach { + case tweet => + val bytes = Injection[Tweet, Array[Byte]](tweet) + info(s"Synchronously sending Tweet $tweet to topic ${producerApp.topic}") + producerApp.send(bytes) + } + + val waitForStormToReadFromKafka = 1.seconds + Thread.sleep(waitForStormToReadFromKafka.toMillis) + } + }) + + Then("the Kafka consumer app should receive the decoded, original tweets from the Storm topology") + val waitForConsumerToReadStormOutput = 300.millis + Thread.sleep(waitForConsumerToReadStormOutput.toMillis) + consumer.shutdown() + actualTweets.toSeq should be(tweets.toSeq) + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/integration/StormSpec.scala b/src/test/scala/com/miguno/kafkastorm/integration/StormSpec.scala new file mode 100644 index 0000000..cd9ea94 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/integration/StormSpec.scala @@ -0,0 +1,113 @@ +package com.miguno.kafkastorm.integration + +import _root_.kafka.utils.Logging +import backtype.storm.{Config, ILocalCluster, Testing} +import backtype.storm.testing._ +import backtype.storm.topology.TopologyBuilder +import backtype.storm.tuple.{Fields, Values} +import org.scalatest._ + +/** + * For more details on Storm unit testing please take a look at: + * https://github.com/xumingming/storm-lib/blob/master/src/jvm/storm/TestingApiDemo.java + */ +@DoNotDiscover +class StormSpec extends FunSpec with Matchers with BeforeAndAfterAll with GivenWhenThen with Logging { + + describe("Storm") { + + it("should start a local cluster", IntegrationTest) { + Given("no cluster") + + When("I start a LocalCluster instance") + val mkClusterParam = new MkClusterParam + mkClusterParam.setSupervisors(2) + mkClusterParam.setPortsPerSupervisor(2) + val daemonConf = new Config + daemonConf.put(Config.SUPERVISOR_ENABLE, false: java.lang.Boolean) + daemonConf.put(Config.TOPOLOGY_ACKER_EXECUTORS, 0: Integer) + mkClusterParam.setDaemonConf(daemonConf) + + // When testing your topology, you need a `LocalCluster` to run your topologies. Normally this would mean you'd + // have to perform lifecycle management of that local cluster, i.e. you'd need to create it, and after using it, + // you'd need to stop it. Using `Testing.withLocalCluster` you don't need to do any of this, just use the + // `cluster` provided through the param of `TestJob.run`.` + Testing.withLocalCluster(mkClusterParam, new TestJob { + override def run(stormCluster: ILocalCluster) { + Then("the local cluster should start properly") + stormCluster.getState shouldNot be(null) + } + }) + } + + it("should run a basic topology", IntegrationTest) { + Given("a local cluster") + And("a wordcount topology") + val mkClusterParam = new MkClusterParam + mkClusterParam.setSupervisors(4) + val daemonConf = new Config + daemonConf.put(Config.STORM_LOCAL_MODE_ZMQ, false: java.lang.Boolean) + mkClusterParam.setDaemonConf(daemonConf) + + // Base topology setup + val builder = new TopologyBuilder + val spoutId = "wordSpout" + builder.setSpout(spoutId, new TestWordSpout(true), 3) + val wordCounterId = "wordCounterBolt" + builder.setBolt(wordCounterId, new TestWordCounter, 4).fieldsGrouping(spoutId, new Fields("word")) + val globalCountId = "globalCountBolt" + builder.setBolt(globalCountId, new TestGlobalCount).globalGrouping(spoutId) + val aggregatesCounterId = "aggregatesCounterBolt" + builder.setBolt(aggregatesCounterId, new TestAggregatesCounter).globalGrouping(wordCounterId) + val topology = builder.createTopology() + val completeTopologyParam = new CompleteTopologyParam + + And("the input words alice, bob, joe, alice") + val mockedSources = new MockedSources() + mockedSources.addMockData(spoutId, new Values("alice"), new Values("bob"), new Values("joe"), new Values("alice")) + completeTopologyParam.setMockedSources(mockedSources) + + // Finalize topology config + val conf = new Config + conf.setNumWorkers(2) + completeTopologyParam.setStormConf(conf) + + When("I submit the topology") + var result: Option[java.util.Map[_, _]] = None + Testing.withSimulatedTimeLocalCluster(mkClusterParam, new TestJob() { + override def run(stormCluster: ILocalCluster) { + // `completeTopology()` takes your topology, cluster, and configuration. It will mock out the spouts you + // specify, and will run the topology until it is idle and all tuples from the spouts have been either acked or + // failed, and return all the tuples that have been emitted from all the topology components. + result = Some(Testing.completeTopology(stormCluster, topology, completeTopologyParam)) + } + }) + + // We could split this `Then()` into multiple ones, each of which covering one of the `Testing.multiseteq()` calls + // below. Left as an exercise for the reader. :-) + Then("the topology should properly count the words") + // Type ascription required for Scala-Java interoperability. + val one = 1: Integer + val two = 2: Integer + val three = 3: Integer + val four = 4: Integer + + // Verify the expected behavior for each of the components (spout + bolts) in the topology by comparing + // their actual output tuples vs. the corresponding expected output tuples. + for { + r <- result + } { + Testing.multiseteq(Testing.readTuples(r, spoutId), + new Values(new Values("alice"), new Values("bob"), new Values("joe"), new Values("alice"))) should be(true) + Testing.multiseteq(Testing.readTuples(r, wordCounterId), + new Values(new Values("alice", one), new Values("alice", two), new Values("bob", one), new Values("joe", one))) should be(true) + Testing.multiseteq(Testing.readTuples(r, globalCountId), + new Values(new Values(one), new Values(two), new Values(three), new Values(four))) should be(true) + Testing.multiseteq(Testing.readTuples(r, aggregatesCounterId), + new Values(new Values(one), new Values(two), new Values(three), new Values(four))) should be(true) + } + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/kafka/KafkaProducerAppSpec.scala b/src/test/scala/com/miguno/kafkastorm/kafka/KafkaProducerAppSpec.scala new file mode 100644 index 0000000..81a05ea --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/kafka/KafkaProducerAppSpec.scala @@ -0,0 +1,59 @@ +package com.miguno.kafkastorm.kafka + +import _root_.kafka.utils.Logging +import java.util.Properties +import org.scalatest.{FunSpec, GivenWhenThen, Matchers} + +class KafkaProducerAppSpec extends FunSpec with Matchers with GivenWhenThen with Logging { + + private val AnyTopic = "some-topic" + private val AnyBrokerList = "a:9092,b:9093" + private val AnyConfigParam = "queue.buffering.max.ms" + private val AnyConfigValue = "12345" + + describe("A KafkaProducerApp") { + + it("should let the user configure the broker list") { + Given("no app") + + When("I create an app with the broker list set to " + AnyBrokerList) + val producerApp = new KafkaProducerApp(AnyTopic, AnyBrokerList) + + Then("the Kafka producer's metadata.broker.list config parameter should be set to this value") + producerApp.config.props.getString("metadata.broker.list") should be(AnyBrokerList) + } + + it("should use the broker list constructor parameter as the authoritative setting for the broker list") { + Given("no app") + + When("I create an app with a producer config that sets the broker list to notMe:1234") + val config = { + val c = new Properties + c.put("metadata.broker.list", "notMe:1234") + c + } + And("with the constructor parameter that sets the broker list to " + AnyBrokerList) + val producerApp = new KafkaProducerApp(AnyTopic, AnyBrokerList, config) + + Then("the Kafka producer's actual broker list should be " + AnyBrokerList) + producerApp.config.props.getString("metadata.broker.list") should be(AnyBrokerList) + } + + it("should let the user customize the Kafka producer configuration") { + Given("no app") + + When(s"I create an app with a producer config that sets $AnyConfigParam to $AnyConfigValue") + val config = { + val c = new Properties + c.put(AnyConfigParam, AnyConfigValue) + c + } + val producerApp = new KafkaProducerApp(AnyTopic, AnyBrokerList, config) + + Then(s"the Kafka producer's $AnyConfigParam parameter should be to set to $AnyConfigValue") + producerApp.config.props.getString(AnyConfigParam) should be(AnyConfigValue) + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/storm/AvroDecoderBoltSpec.scala b/src/test/scala/com/miguno/kafkastorm/storm/AvroDecoderBoltSpec.scala new file mode 100644 index 0000000..fb98046 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/storm/AvroDecoderBoltSpec.scala @@ -0,0 +1,144 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.topology.{BasicOutputCollector, OutputFieldsDeclarer} +import backtype.storm.tuple.{Fields, Tuple, Values} +import com.miguno.avro.Tweet +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import org.mockito.Matchers._ +import org.mockito.Mockito.{when => mwhen, _} +import org.scalatest.{FunSpec, GivenWhenThen, Matchers} +import org.scalatest.mock.MockitoSugar +import scala.concurrent.duration._ + +class AvroDecoderBoltSpec extends FunSpec with Matchers with GivenWhenThen with MockitoSugar { + + implicit val specificAvroBinaryInjection: Injection[Tweet, Array[Byte]] = SpecificAvroCodecs.toBinary[Tweet] + + private type AnyAvroSpecificRecordBase = Tweet + + private val AnyTweet = new Tweet("ANY_USER_1", "ANY_TEXT_1", 1234.seconds.toSeconds) + private val AnyTweetInAvroBytes = Injection[Tweet, Array[Byte]](AnyTweet) + + describe("An AvroDecoderBolt") { + + it("should read by default the input field 'bytes' from incoming tuples") { + Given("no bolt") + + When("I create a bolt without customizing the input field name") + val bolt = new AvroDecoderBolt[AnyAvroSpecificRecordBase] + And("the bolt receives a tuple") + val tuple = mock[Tuple] + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("the bolt should read the field 'bytes' from the tuple") + verify(tuple, times(1)).getBinaryByField("bytes") + } + + it("should let the user configure the name of the input field to read from incoming tuples") { + Given("no bolt") + + When("I create a bolt with a custom input field name 'foobar'") + val bolt = new AvroDecoderBolt[AnyAvroSpecificRecordBase](inputField = "foobar") + And("the bolt receives a tuple") + val tuple = mock[Tuple] + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("the bolt should read the field 'foobar' from the tuple") + verify(tuple, times(1)).getBinaryByField("foobar") + } + + it("should deserialize binary records into pojos and send the pojos to downstream bolts") { + Given("a bolt of type Tweet") + val bolt = new AvroDecoderBolt[Tweet] + And("a Tweet record") + val tuple = mock[Tuple] + mwhen(tuple.getBinaryByField(anyString)).thenReturn(AnyTweetInAvroBytes) + + When("the bolt receives the Tweet record") + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("the bolt should send the decoded Tweet pojo to downstream bolts") + verify(collector, times(1)).emit(new Values(AnyTweet)) + } + + it("should skip over tuples that contain invalid binary records") { + Given("a bolt of type Tweet") + val bolt = new AvroDecoderBolt[Tweet] + And("an invalid binary record") + val tuple = mock[Tuple] + val invalidBinaryRecord = Array[Byte](1, 2, 3, 4) + mwhen(tuple.getBinaryByField(anyString)).thenReturn(invalidBinaryRecord) + + When("the bolt receives the record") + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("the bolt should not send any data to downstream bolts") + verifyZeroInteractions(collector) + } + + it("should skip over tuples for which reading fails") { + Given("a bolt") + val bolt = new AvroDecoderBolt[AnyAvroSpecificRecordBase] + And("a tuple from which one cannot read") + val tuple = mock[Tuple] + mwhen(tuple.getBinaryByField(anyString)).thenReturn(null) + + When("the bolt receives the tuple") + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("the bolt should not send any data to downstream bolts") + verifyZeroInteractions(collector) + } + + it("should declare a single output field with the default name 'pojo'") { + Given("no bolt") + + When("I create a bolt without customizing the output field name") + val bolt = new AvroDecoderBolt[Tweet] + + Then("the bolt should declare a single output field named 'pojo'") + val declarer = mock[OutputFieldsDeclarer] + bolt.declareOutputFields(declarer) + // We use ArgumentMatcher as a workaround because Storm's Field class does not implement a proper `equals()` + // method, and Mockito relies on `equals()` for verification. Because of that the following typical approach + // does NOT work: `verify(declarer, times(1)).declare(new Fields("pojo"))`. + verify(declarer, times(1)).declare(argThat(FieldsEqualTo(new Fields("pojo")))) + } + + it("should let the user define the name of its output field") { + Given("no bolt") + + When("I create a bolt with a custom output field name") + val bolt = new AvroDecoderBolt[Tweet](outputField = "myCustomFieldName") + + Then("the bolt should declare a single output field with this custom name") + val declarer = mock[OutputFieldsDeclarer] + bolt.declareOutputFields(declarer) + verify(declarer, times(1)).declare(argThat(FieldsEqualTo(new Fields("myCustomFieldName")))) + } + + } + + describe("An AvroDecoderBolt companion object") { + + it("should create an AvroDecoderBolt for the correct type") { + Given("a companion object") + + When("I ask it to create a bolt for type Tweet") + val bolt = AvroDecoderBolt.ofType(classOf[Tweet]) + + Then("the bolt should be an AvroDecoderBolt") + bolt shouldBe an[AvroDecoderBolt[_]] + And("the bolt should be parameterized with the type Tweet") + bolt.tpe.shouldEqual(manifest[Tweet]) + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBoltSpec.scala b/src/test/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBoltSpec.scala new file mode 100644 index 0000000..6306234 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/storm/AvroKafkaSinkBoltSpec.scala @@ -0,0 +1,113 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.task.TopologyContext +import backtype.storm.topology.{BasicOutputCollector, OutputFieldsDeclarer} +import backtype.storm.tuple.{Fields, Tuple} +import com.miguno.avro.Tweet +import com.miguno.kafkastorm.kafka.{KafkaProducerApp, KafkaProducerAppFactory} +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import java.util +import org.mockito.AdditionalMatchers +import org.mockito.Matchers.argThat +import org.mockito.Mockito.{when => mwhen, _} +import org.scalatest.{FunSpec, GivenWhenThen, Matchers} +import org.scalatest.mock.MockitoSugar +import scala.concurrent.duration._ + +class AvroKafkaSinkBoltSpec extends FunSpec with Matchers with GivenWhenThen with MockitoSugar { + + implicit val specificAvroBinaryInjection: Injection[Tweet, Array[Byte]] = SpecificAvroCodecs.toBinary[Tweet] + + private type AnyAvroSpecificRecordBase = Tweet + + private val AnyTweet = new Tweet("ANY_USER_1", "ANY_TEXT_1", 1234.seconds.toSeconds) + private val AnyTweetInAvroBytes = Injection[Tweet, Array[Byte]](AnyTweet) + private val DummyStormConf = new util.HashMap[Object, Object] + private val DummyStormContext = mock[TopologyContext] + + describe("An AvroKafkaSinkBolt") { + + it("should send pojos of the configured type to Kafka in Avro-encoded binary format") { + Given("a bolt for type Tweet") + val producerApp = mock[KafkaProducerApp] + val producerAppFactory = mock[KafkaProducerAppFactory] + mwhen(producerAppFactory.newInstance()).thenReturn(producerApp) + val bolt = new AvroKafkaSinkBolt[Tweet](producerAppFactory) + bolt.prepare(DummyStormConf, DummyStormContext) + + When("it receives a Tweet pojo") + val tuple = mock[Tuple] + // The `Nil: _*` is required workaround because of a known Scala-Java interop problem related to Scala's treatment + // of Java's varargs. See http://stackoverflow.com/a/13361530/1743580. + mwhen(tuple.getValueByField("pojo")).thenReturn(AnyTweet, Nil: _*) + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("it should send the Avro-encoded pojo to Kafka") + // Note: The simpler Mockito variant of `verify(kafkaProducer).send(AnyTweetInAvroBytes)` is not enough because + // this variant will not verify whether the Array[Byte] parameter passed to `send()` has the correct value. + verify(producerApp).send(AdditionalMatchers.aryEq(AnyTweetInAvroBytes)) + And("it should not send any data to downstream bolts") + verifyZeroInteractions(collector) + } + + it("should ignore pojos of an unexpected type") { + Given("a bolt for type Tweet") + val producerApp = mock[KafkaProducerApp] + val producerAppFactory = mock[KafkaProducerAppFactory] + mwhen(producerAppFactory.newInstance()).thenReturn(producerApp) + val bolt = new AvroKafkaSinkBolt[Tweet](producerAppFactory) + bolt.prepare(DummyStormConf, DummyStormContext) + + When("receiving a non-Tweet pojo") + val tuple = mock[Tuple] + val invalidPojo = "I am not of the expected type!" + // The `Nil: _*` is required workaround because of a known Scala-Java interop problem related to Scala's treatment + // of Java's varargs. See http://stackoverflow.com/a/13361530/1743580. + mwhen(tuple.getValueByField("pojo")).thenReturn(invalidPojo, Nil: _*) + val collector = mock[BasicOutputCollector] + bolt.execute(tuple, collector) + + Then("it should not send any data to Kafka") + verifyZeroInteractions(producerApp) + And("it should not send any data to downstream bolts") + verifyZeroInteractions(collector) + } + + it("should not declare any output fields") { + Given("no bolt") + + When("I create a bolt") + val producerAppFactory = mock[KafkaProducerAppFactory] + val bolt = new AvroKafkaSinkBolt[AnyAvroSpecificRecordBase](producerAppFactory) + + Then("it should declare zero output fields") + val declarer = mock[OutputFieldsDeclarer] + bolt.declareOutputFields(declarer) + // We use ArgumentMatcher as a workaround because Storm's Field class does not implement a proper `equals()` + // method, and Mockito relies on `equals()` for verification. Because of that the following typical approach + // does NOT work: `verify(declarer, times(1)).declare(new Fields())`. + verify(declarer, times(1)).declare(argThat(FieldsEqualTo(new Fields()))) + } + + } + + describe("An AvroKafkaSinkBolt companion object") { + + it("should create an AvroKafkaSinkBolt for the correct type") { + Given("a companion object") + + When("I ask it to create a bolt for type Tweet") + val producerAppFactory = mock[KafkaProducerAppFactory] + val bolt = AvroKafkaSinkBolt.ofType(classOf[Tweet])(producerAppFactory) + + Then("the bolt should be an AvroKafkaSinkBolt") + bolt shouldBe an[AvroKafkaSinkBolt[_]] + And("the bolt should be parameterized with the type Tweet") + bolt.tpe.shouldEqual(manifest[Tweet]) + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala b/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala new file mode 100644 index 0000000..5b7a2be --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala @@ -0,0 +1,96 @@ +package com.miguno.kafkastorm.storm + +import com.miguno.avro.Tweet +import com.twitter.bijection.Injection +import com.twitter.bijection.avro.SpecificAvroCodecs +import org.scalatest.{FunSpec, GivenWhenThen, Matchers} +import scala.collection.JavaConverters._ +import scala.concurrent.duration._ + +class AvroSchemeSpec extends FunSpec with Matchers with GivenWhenThen { + + implicit val specificAvroBinaryInjectionForTweet = SpecificAvroCodecs.toBinary[Tweet] + + val fixture = { + val BeginningOfEpoch = 0.seconds + val AnyTimestamp = 1234.seconds + val now = System.currentTimeMillis().millis + + new { + val t1 = new Tweet("ANY_USER_1", "ANY_TEXT_1", now.toSeconds) + val t2 = new Tweet("ANY_USER_2", "ANY_TEXT_2", BeginningOfEpoch.toSeconds) + val t3 = new Tweet("ANY_USER_3", "ANY_TEXT_3", AnyTimestamp.toSeconds) + + val messages = Seq(t1, t2, t3) + } + } + + describe("An AvroScheme") { + + it("should have a single output field named 'pojo'") { + Given("a scheme") + val scheme = new AvroScheme + + When("I get its output fields") + val outputFields = scheme.getOutputFields() + + Then("there should only be a single field") + outputFields.size() should be(1) + + And("this field should be named 'pojo'") + outputFields.contains("pojo") should be(true) + } + + + it("should deserialize binary records of the configured type into pojos") { + Given("a scheme for type Tweet ") + val scheme = new AvroScheme[Tweet] + And("some binary-encoded Tweet records") + val f = fixture + val encodedTweets = f.messages.map(Injection[Tweet, Array[Byte]]) + + When("I deserialize the records into pojos") + val actualTweets = for { + l <- encodedTweets.map(scheme.deserialize) + tweet <- l.asScala + } yield tweet + + Then("the pojos should be equal to the original pojos") + actualTweets should be(f.messages) + } + + it("should throw a runtime exception when serialization fails") { + Given("a scheme for type Tweet ") + val scheme = new AvroScheme[Tweet] + And("an invalid binary record") + val invalidBytes = Array[Byte](1, 2, 3, 4) + + When("I deserialize the record into a pojo") + + Then("the scheme should throw a runtime exception") + val exception = intercept[RuntimeException] { + scheme.deserialize(invalidBytes) + } + And("the exception should provide a meaningful explanation") + exception.getMessage should be("Could not decode input bytes") + } + + } + + describe("An AvroScheme companion object") { + + it("should create an AvroScheme for the correct type") { + Given("a companion object") + + When("I ask it to create a scheme for type Tweet") + val scheme = AvroScheme.ofType(classOf[Tweet]) + + Then("the scheme should be an AvroScheme") + scheme shouldBe an[AvroScheme[_]] + And("the scheme should be parameterized with the type Tweet") + scheme.tpe.shouldEqual(manifest[Tweet]) + } + + } + +} \ No newline at end of file diff --git a/src/test/scala/com/miguno/kafkastorm/storm/FieldsEqualTo.scala b/src/test/scala/com/miguno/kafkastorm/storm/FieldsEqualTo.scala new file mode 100644 index 0000000..7f8c741 --- /dev/null +++ b/src/test/scala/com/miguno/kafkastorm/storm/FieldsEqualTo.scala @@ -0,0 +1,30 @@ +package com.miguno.kafkastorm.storm + +import backtype.storm.tuple.Fields +import org.mockito.ArgumentMatcher +import scala.collection.JavaConverters._ + +/** + * [[org.mockito.ArgumentMatcher]] for Storm's [[backtype.storm.tuple.Fields]]. + * + * @example {{{ + * // Verify that a single field named "pojo" is declared. + * verify(declarer).declare(argThat(FieldsEqualTo(new Fields("pojo")))) + * }}} + * + * ==Why this approach is required== + * We must use an ArgumentMatcher as a workaround because Storm's Field class does not implement a proper `equals()` + * method, and Mockito relies on `equals()` for verification. Because of that the following intuitive approach for + * Mockito does not work: `verify(declarer, times(1)).declare(new Fields("bytes"))`. + * @param expectedFields + */ +class FieldsEqualTo(val expectedFields: Fields) extends ArgumentMatcher[Fields] { + override def matches(o: scala.Any): Boolean = { + val fields = o.asInstanceOf[Fields].toList.asScala + fields == expectedFields.toList.asScala + } +} + +object FieldsEqualTo { + def apply(expFields: Fields) = new FieldsEqualTo(expFields) +} \ No newline at end of file diff --git a/version.sbt b/version.sbt new file mode 100644 index 0000000..57b0bcb --- /dev/null +++ b/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1.0-SNAPSHOT" From e53097d4ac6f79f3d68e95301df79a0065229b51 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Fri, 23 May 2014 17:25:06 +0200 Subject: [PATCH 02/12] Add link to unit tests --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 07fd587..49480a9 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,10 @@ What features do we showcase in kafka-storm-starter? Note that we focus on show [KafkaStormSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala) * A Storm topology that writes Avro-encoded data to Kafka: [KafkaStormSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala) +* Unit testing + * [AvroDecoderBoltSpec](src/test/scala/com/miguno/kafkastorm/storm/AvroDecoderBoltSpec.scala) + * [AvroSchemeSpec](src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala) + * And more under [src/test/scala](src/test/scala/com/miguno/kafkastorm/). * Integration testing * [KafkaSpec](src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala): Tests for Kafka, which launch and run against in-memory instances of Kafka and ZooKeeper. From 151917cf28dd22cf3e5cac93a7dc2955e404548e Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:25:53 +0200 Subject: [PATCH 03/12] Jenkins: use correct name/location of Cobertura XML file --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49480a9..0483ab0 100644 --- a/README.md +++ b/README.md @@ -405,7 +405,9 @@ Run the unit tests via: $ ./sbt clean scoverage:test * An HTML report will be created at `target/scala-2.10/scoverage-report/index.html`. -* An XML report will be created at `./target/scala-2.10/scoverage-report/scoverage.xml`. +* XML reports will be created at: + * `./target/scala-2.10/coverage-report/cobertura.xml` + * `./target/scala-2.10/scoverage-report/scoverage.xml` Integration with CI servers: @@ -413,7 +415,7 @@ Integration with CI servers: * Configure the build. * Go to _Post-build Actions_. * Add a post-build action for _Publish Cobertura Coverage Report_. - * In the _Cobertura xml report pattern_ field add the pattern `**/target/scala-2.10/scoverage-report/scoverage.xml`. + * In the _Cobertura xml report pattern_ field add the pattern `**/target/scala-2.10/coverage-report/cobertura.xml`. * Now each build of your job will have a _Coverage Report_ link. * TeamCity integration: * Edit the build configuration. From 0c4892f4598f45cda88ba4c850c0a5f71c361569 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:34:29 +0200 Subject: [PATCH 04/12] Java options: enable headless mode to fix Jenkins build problems on Mac --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index 2a3bb86..99a56b9 100644 --- a/build.sbt +++ b/build.sbt @@ -71,6 +71,8 @@ libraryDependencies ++= Seq( // merely as a test dependency), which we need for TypeTag usage. libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) +javaOptions ++= Seq("-Djava.awt.headless=true") + publishArtifact in Test := false parallelExecution in Test := false From 4449c90748dcca8e3c8f3d297e8a652aae6d13be Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:34:48 +0200 Subject: [PATCH 05/12] Java options: set maximum heap size of 512 MB --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 99a56b9..d04d88e 100644 --- a/build.sbt +++ b/build.sbt @@ -71,7 +71,7 @@ libraryDependencies ++= Seq( // merely as a test dependency), which we need for TypeTag usage. libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) -javaOptions ++= Seq("-Djava.awt.headless=true") +javaOptions ++= Seq("-Xmx512m", "-Djava.awt.headless=true") publishArtifact in Test := false From ea73957119373d9cca4b8692350f966d948d5b2e Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:36:47 +0200 Subject: [PATCH 06/12] Set -deprecation and -unchecked scalac options --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index d04d88e..b907990 100644 --- a/build.sbt +++ b/build.sbt @@ -73,6 +73,8 @@ libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) javaOptions ++= Seq("-Xmx512m", "-Djava.awt.headless=true") +scalacOptions ++= Seq("-unchecked", "-deprecation") + publishArtifact in Test := false parallelExecution in Test := false From 2b766f8c48b1378876810beb4fd5f4e7b4ca0ae3 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:54:17 +0200 Subject: [PATCH 07/12] Move Java options to .sbtopts Otherwise ./sbt apparently does not pick up the java.awt.headless property. --- .sbtopts | 2 ++ build.sbt | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .sbtopts diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..f38f1cb --- /dev/null +++ b/.sbtopts @@ -0,0 +1,2 @@ +-J-Xmx512m +-Djava.awt.headless=true diff --git a/build.sbt b/build.sbt index b907990..f2f17ce 100644 --- a/build.sbt +++ b/build.sbt @@ -71,8 +71,6 @@ libraryDependencies ++= Seq( // merely as a test dependency), which we need for TypeTag usage. libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) -javaOptions ++= Seq("-Xmx512m", "-Djava.awt.headless=true") - scalacOptions ++= Seq("-unchecked", "-deprecation") publishArtifact in Test := false From 2305aeb9a687bda6d14de813f3257cec4c015b80 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Sat, 24 May 2014 09:57:20 +0200 Subject: [PATCH 08/12] Set -feature scalac options --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index f2f17ce..5e93238 100644 --- a/build.sbt +++ b/build.sbt @@ -71,7 +71,7 @@ libraryDependencies ++= Seq( // merely as a test dependency), which we need for TypeTag usage. libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _) -scalacOptions ++= Seq("-unchecked", "-deprecation") +scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature") publishArtifact in Test := false From baf0cf284411c1f1c8728b1780cf1f2b8e3718e9 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Tue, 27 May 2014 09:17:05 +0200 Subject: [PATCH 09/12] Fix `-feature` warning about reflective access of structural type member value Example warning: [warn] KafkaSpec.scala:98: reflective access of structural type member value messages should be enabled [warn] by making the implicit value scala.language.reflectiveCalls visible. [warn] This can be achieved by adding the import clause 'import scala.language.reflectiveCalls' [warn] or by setting the compiler option -language:reflectiveCalls. --- src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala | 1 + .../scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala | 1 + src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala | 1 + 3 files changed, 3 insertions(+) diff --git a/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala b/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala index e58485a..c4697a1 100644 --- a/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala +++ b/src/test/scala/com/miguno/kafkastorm/integration/KafkaSpec.scala @@ -12,6 +12,7 @@ import org.I0Itec.zkclient.ZkClient import org.scalatest._ import scala.collection.mutable import scala.concurrent.duration._ +import scala.language.reflectiveCalls import kafka.admin.AdminUtils @DoNotDiscover diff --git a/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala b/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala index ed2d8e7..c53f32f 100644 --- a/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala +++ b/src/test/scala/com/miguno/kafkastorm/integration/KafkaStormSpec.scala @@ -20,6 +20,7 @@ import org.I0Itec.zkclient.ZkClient import org.scalatest._ import scala.collection.mutable import scala.concurrent.duration._ +import scala.language.reflectiveCalls /** * This Kafka/Storm integration test code is slightly more complicated than the other tests in this project. This is diff --git a/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala b/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala index 5b7a2be..018f275 100644 --- a/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala +++ b/src/test/scala/com/miguno/kafkastorm/storm/AvroSchemeSpec.scala @@ -6,6 +6,7 @@ import com.twitter.bijection.avro.SpecificAvroCodecs import org.scalatest.{FunSpec, GivenWhenThen, Matchers} import scala.collection.JavaConverters._ import scala.concurrent.duration._ +import scala.language.reflectiveCalls class AvroSchemeSpec extends FunSpec with Matchers with GivenWhenThen { From 4d445d55eb6eb42a9dca0ad1bc7d5981e5127297 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Tue, 27 May 2014 09:18:38 +0200 Subject: [PATCH 10/12] Use ScalaTest 2.1.6 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 5e93238..e7ac3bc 100644 --- a/build.sbt +++ b/build.sbt @@ -63,7 +63,7 @@ libraryDependencies ++= Seq( "ch.qos.logback" % "logback-core" % "1.1.2", "org.slf4j" % "slf4j-api" % "1.7.7", // Test dependencies - "org.scalatest" %% "scalatest" % "2.1.5" % "test", + "org.scalatest" %% "scalatest" % "2.1.6" % "test", "org.mockito" % "mockito-all" % "1.9.5" % "test" ) From 1c17bbac69ba7574b584bc3b47f84e5ccd70810c Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Tue, 27 May 2014 09:21:41 +0200 Subject: [PATCH 11/12] Add change log --- CHANGELOG.md | 3 +++ README.md | 8 ++++++++ 2 files changed, 11 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5be4bbe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0 (May 27, 2014) + +* Initial release. Integrates Kafka 0.8.1.1 with Storm 0.9.1-incubating. diff --git a/README.md b/README.md index 0483ab0..e668b2e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Table of Contents * Known issues and limitations * Upstream code * kafka-storm-starter code +* Change log * Contributing * License * References @@ -679,6 +680,13 @@ time. * We noticed that the tests may fail when using Oracle/Sun JDK 1.6.0_24. Later versions (e.g. 1.6.0_31) work fine. + + +# Change log + +See [CHANGELOG](CHANGELOG.md). + + # Contributing to kafka-storm-starter From 63bbff78cf4592dbf113fff9b497fd566644b034 Mon Sep 17 00:00:00 2001 From: "Michael G. Noll" Date: Tue, 27 May 2014 09:25:05 +0200 Subject: [PATCH 12/12] Bump version to 0.1.0 --- sonar-project.properties | 2 +- version.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index bc62f33..43696f2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ # Required metadata sonar.projectKey=com.miguno.kafkastorm:kafka-storm-starter sonar.projectName=kafka-storm-starter -sonar.projectVersion=0.1.0-SNAPSHOT +sonar.projectVersion=0.1.0 # Base configuration of paths sonar.sources=src/main/java,src/main/scala diff --git a/version.sbt b/version.sbt index 57b0bcb..e765444 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.1.0-SNAPSHOT" +version in ThisBuild := "0.1.0"