├── .gitignore ├── LICENSE.txt ├── OneBreadcrumb_v0.dat ├── README.md ├── build.sbt ├── conf └── mqtt-loadtest.conf ├── project ├── build.properties └── plugins.sbt ├── src ├── main │ ├── resources │ │ ├── application.conf │ │ └── logback.xml │ └── scala │ │ └── io │ │ └── m2m │ │ ├── kafka │ │ └── KafkaConsumer.scala │ │ └── mqtt │ │ ├── Config.scala │ │ ├── LoadTest.scala │ │ ├── QueueSubscriber.scala │ │ ├── SharedSubscriber.scala │ │ ├── SplunkLogger.scala │ │ ├── Validation.scala │ │ ├── WebServer.scala │ │ ├── XenPublisher.scala │ │ └── XenqttCallback.scala └── test │ └── scala │ └── io │ └── m2m │ └── mqtt │ └── ConfigSpec.scala └── startLoad.sh /.gitignore: -------------------------------------------------------------------------------- 1 | publisher*/ 2 | WEBSOCKET*/ 3 | target/ 4 | *.swp 5 | sent-messages.dat 6 | received-messages.dat 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015 2lemetry, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /OneBreadcrumb_v0.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amazon-archives/mqtt-loadtest/09374bb94adc92e4778dc83c9aadbed9e318ac4c/OneBreadcrumb_v0.dat -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Archived 2 | 3 | This project has been archived and is no longer supported by 2lemetry/Amazon Web Services. 4 | 5 | ## Summary 6 | 7 | A configurable benchmark tool for MQTT to test brokers. 8 | 9 | 10 | Running 11 | ------- 12 | 13 | 1. Use SBT to compile and `sbt start-script` to generate a start script. 14 | 15 | sbt update compile start-script 16 | 17 | 2. Edit `src/main/resources/application.conf` to change the settings to what you need. 18 | 3. Run via either `sbt run` or `target/start.sh` 19 | 20 | ## License 21 | 22 | Copyright 2015 2lemetry, Inc. 23 | 24 | Licensed under the Apache License, Version 2.0 (the "License"); 25 | you may not use this file except in compliance with the License. 26 | You may obtain a copy of the License at 27 | 28 | http://www.apache.org/licenses/LICENSE-2.0 29 | 30 | Unless required by applicable law or agreed to in writing, software 31 | distributed under the License is distributed on an "AS IS" BASIS, 32 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 33 | See the License for the specific language governing permissions and 34 | limitations under the License. 35 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.typesafe.sbt.SbtStartScript 2 | 3 | seq(SbtStartScript.startScriptForClassesSettings: _*) 4 | 5 | name := "mqtt-loadtest" 6 | 7 | version := "0.1-SNAPSHOT" 8 | 9 | organization := "io.m2m.mqtt" 10 | 11 | scalaVersion := "2.10.2" 12 | 13 | scalacOptions += "-deprecation" 14 | 15 | resolvers ++= Seq( 16 | "Maven Repository" at "http://repo1.maven.org/maven2/", 17 | "Typsafe" at "http://repo.typesafe.com/typesafe/releases", 18 | "sonatype-releases" at "https://oss.sonatype.org/content/repositories/releases", 19 | "Eclipse" at "https://repo.eclipse.org/content/repositories/paho-releases/", 20 | "scct-github-repository" at "http://mtkopone.github.com/scct/maven-repo" 21 | ) 22 | 23 | libraryDependencies ++= Seq( 24 | "com.typesafe.akka" %% "akka-actor" % "2.2.0", 25 | "com.typesafe.akka" %% "akka-remote" % "2.2.0", 26 | "com.typesafe" % "config" % "1.0.2", 27 | "org.scala-lang" % "scala-reflect" % "2.10.2", 28 | "org.joda" % "joda-convert" % "1.4", 29 | "joda-time" % "joda-time" % "2.3", 30 | "org.fusesource.mqtt-client" % "mqtt-client" % "1.5", 31 | "org.json4s" %% "json4s-native" % "3.1.0", 32 | "org.scalatest" %% "scalatest" % "2.0.M5b" % "test", 33 | "org.mashupbots.socko" %% "socko-webserver" % "0.3.0", 34 | "net.databinder.dispatch" %% "dispatch-core" % "0.11.0", 35 | "ch.qos.logback" % "logback-classic" %"1.0.1", 36 | "org.apache.kafka" % "kafka_2.10" % "0.8.0" exclude("com.sun.jdmk", "jmxtools") exclude("com.sun.jmx", "jmxri"), 37 | "net.sf.xenqtt" % "xenqtt" % "0.9.3" 38 | ) 39 | 40 | -------------------------------------------------------------------------------- /conf/mqtt-loadtest.conf: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 2lemetry MQTT-Loadtest 3 | ################################################################################ 4 | description "2lemetry MQTT-Loadtest" 5 | version "1.0" 6 | 7 | setuid ubuntu 8 | setgid nogroup 9 | umask 022 10 | limit nofile 1048576 1048576 11 | 12 | chdir /home/ubuntu/mqtt-loadtest 13 | 14 | stop on runlevel [023456] 15 | 16 | pre-start script 17 | if [ ! -f "./sbt" ]; then 18 | wget http://repo.typesafe.com/typesafe/ivy-releases/org.scala-sbt/sbt-launch/0.12.4/sbt-launch.jar 19 | echo 'java -Dsun.net.inetaddr.ttl=0 -Xmx6g -jar `dirname $0`/sbt-launch.jar "$*"' > ./sbt 20 | chmod a+x ./sbt 21 | ./sbt update 22 | fi 23 | end script 24 | 25 | script 26 | ./sbt run > run.log 27 | end script 28 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.12.4 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-start-script" % "0.6.0") 2 | 3 | addSbtPlugin("org.ensime" % "ensime-sbt-cmd" % "0.1.1") 4 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | host = "localhost" 2 | port = 1883 3 | //username = "tim@2lemetry.com" 4 | //password = "9fabc7a9da96911c9f4ca5f2d15518db" 5 | // Set this to true if password is already hashed. Default is true 6 | pwNeedsHashing = false 7 | 8 | millis-between-connects = 25 9 | keep-alive = 0 10 | 11 | // Set this to true to include a column for average latency per message. 12 | // This appends a topic segment for message ID. So publishing to topic 13 | // `com.example/devices/1234` is actually `com.example/devices/1234/abc123` 14 | // and subscribing to `com.example/devices/+` actually subscribes to 15 | // `com.example/devices/+/+` 16 | trace-latency = true 17 | 18 | publishers { 19 | client-id-prefix = pub2- 20 | topic = "public/kafka/test" 21 | count = 40 22 | millis-between-publish = 20 23 | qos = 1 24 | retain = false 25 | clean-session = true 26 | 27 | // The time (in seconds) that each publisher should stay alive for. 28 | time-span = 90 29 | 30 | payload { 31 | samples = [ 32 | { 33 | #size = 2560 34 | text = "The place has bedeviled boaters pretty much ever since. The Brookville, a schooner that spent most of the 19th century hauling freight along the northern coast, ran so far aground on Hart Island during the winter of 1879 that her owners abandoned her as a total loss. The Montauk Steamship Line’s Shinnecock ferry was carrying 150 passengers on its regular route from Rhode Island to New York when it struck Hart Island on the morning of July 15, 1907, in fog. It took seven hours, two tugboats and high tide to pull her free." 35 | } 36 | ] 37 | } 38 | 39 | dispatcher { 40 | type = Dispatcher 41 | executor = "fork-join-executor" 42 | fork-join-executor { 43 | parallelism-min = 4 44 | parallelism-max = 8 45 | } 46 | } 47 | } 48 | 49 | subscribers { 50 | shared = false 51 | topic = "public/#" 52 | client-id-prefix = scatter-1 53 | count = 2 54 | qos = 1 55 | clean-session = false 56 | // The time (in seconds) that each subscriber should stay alive for. 57 | time-span = 180 58 | 59 | 60 | dispatcher { 61 | type = Dispatcher 62 | executor = "fork-join-executor" 63 | fork-join-executor { 64 | parallelism-factor = 3.0 65 | parallelism-min = 4 66 | parallelism-max = 8 67 | } 68 | } 69 | } 70 | 71 | kafka { 72 | // Set this to false to disable Kafka consumer and enable MQTT subscriber. 73 | enabled = true 74 | 75 | // zookeeper 76 | zkConnect = "localhost:2181" 77 | zkTimeout = 1000000 78 | 79 | // Set the ID for the consumer group. If you launch another node with this config, it'll share the partitions 80 | // evenly across the second node. 81 | groupId = test-group 82 | 83 | // topic can't have "/" in it, only a-zA-Z0-9_- 84 | topic = public-kafka-test 85 | 86 | // The number of threads to use. 87 | // 1. If parallelism > # of partitions, extra threads will do nothing 88 | // 2. if parallelism < # of partitions, extra partitions will be divided up (coordinated using zookeeper) 89 | parallelism = 40 90 | } 91 | 92 | queue-monitor { 93 | clientid = "queue-monitor" 94 | topic = "com.peoplenetonline/$SYS/queues" 95 | } 96 | 97 | splunk { 98 | enabled = true 99 | user = "m2mIO" 100 | password = "dLjmQGKc2XxdLx9z7LuvPmdOzgJp6W8LJ0d1x1hMz_PBWtrx9oCHrFLBgnBVVpwrL1UUbyLGzMY=" 101 | url = "https://api.splunkstorm.com/1/inputs/http" 102 | project = "8df77352d46f11e298231231390fa0c1" 103 | } 104 | 105 | # For Socko 106 | http { 107 | hostname = "0.0.0.0" 108 | port = 8888 109 | } 110 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | dao.log 4 | 5 | 6 | dao.%i.log.zip 7 | 1 8 | 10 9 | 10 | 11 | 12 | 100MB 13 | 14 | 15 | %d %-5level %logger{35} - %msg%n 16 | 17 | 18 | 19 | 20 | 21 | %-5level %logger{35} - %msg%n 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/kafka/KafkaConsumer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package io.m2m.kafka 19 | 20 | import akka.actor.{ActorSystem, Actor} 21 | import io.m2m.mqtt.{Reporter, Init, Sub} 22 | import java.util.Properties 23 | import kafka.producer.ProducerConfig 24 | import kafka.consumer.{Consumer, ConsumerConfig} 25 | import java.util.concurrent.Executors 26 | import akka.util.ByteString 27 | import com.typesafe.config.Config 28 | import scala.util.Try 29 | 30 | object KafkaConsumer { 31 | case class KafkaConfig(enabled: Boolean, zkConnect: String, zkTimeout: Int, groupId: String, 32 | topic: String, parallelism: Int) 33 | object KafkaConfig { 34 | def get(config: Config) = { 35 | val cfg = Try(KafkaConfig( 36 | Try(config.getBoolean("kafka.enabled")).getOrElse(false), 37 | config.getString("kafka.zkConnect"), 38 | config.getInt("kafka.zkTimeout"), 39 | config.getString("kafka.groupId"), 40 | config.getString("kafka.topic"), 41 | config.getInt("kafka.parallelism") 42 | )) 43 | cfg.recover { 44 | case ex: Throwable => println(ex.getMessage) 45 | } 46 | cfg.filter(_.enabled).toOption 47 | } 48 | } 49 | 50 | def start(config: KafkaConfig) { 51 | val client = { 52 | val props = new Properties 53 | props.put("zookeeper.connect", config.zkConnect) 54 | props.put("zookeeper.connectiontimeout.ms", config.zkTimeout.toString) 55 | props.put("group.id", config.groupId) 56 | val cfg = new ConsumerConfig(props) 57 | Consumer.create(cfg) 58 | } 59 | 60 | val streams = client.createMessageStreams(Map(config.topic -> config.parallelism)).apply(config.topic) 61 | val executor = Executors.newFixedThreadPool(config.parallelism) 62 | streams.zipWithIndex.foreach { case (stream, i) => 63 | executor.submit(new Runnable { 64 | def run() { 65 | try { 66 | Reporter.addSubscriber() 67 | for (msg <- stream) { 68 | val key = ByteString(msg.key).utf8String 69 | // The assumption is that when we create a rule, we substitute all / for -. The / is a special 70 | // character in Kafka topics (filesystem character), so we have to use either - or _. 71 | val mqttTopic = msg.topic.replace('-', '/') + '/' + key.replace('-', '/') 72 | Reporter.messageArrived(mqttTopic, msg.message) 73 | } 74 | } finally { 75 | Reporter.lostSubscriber() 76 | } 77 | } 78 | }) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/Config.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | package io.m2m.mqtt 20 | 21 | import com.typesafe.config.{ConfigValue, ConfigFactory, Config => TSConfig} 22 | import scala.reflect.io.Streamable 23 | import java.io.FileInputStream 24 | import scala.util.{Random, Try} 25 | import scala.collection.JavaConversions._ 26 | import java.util.UUID 27 | 28 | case class SplunkConfig(splunkUser: String, splunkPass: String, splunkUrl: String, splunkProject: String) 29 | 30 | object Config { 31 | val configFactory = ConfigFactory.load() 32 | 33 | val splunkConf = configFactory.getBoolean("splunk.enabled") match { 34 | case true => 35 | val splunkUser = configFactory.getString("splunk.user") 36 | val splunkPass = configFactory.getString("splunk.password") 37 | val splunkUrl = configFactory.getString("splunk.url") 38 | val splunkProject = configFactory.getString("splunk.project") 39 | Option(SplunkConfig(splunkUser, splunkPass, splunkUrl, splunkProject)) 40 | case false => None 41 | } 42 | 43 | 44 | def payload(config: TSConfig): MessageSource = { 45 | def getFile(cfg: TSConfig) = FileMessage(cfg.getString("file")) 46 | def getUTF(cfg: TSConfig) = Utf8Message(cfg.getString("text")) 47 | def getGenerated(cfg: TSConfig) = GeneratedMessage(cfg.getInt("size")) 48 | def getSamples(cfg: TSConfig) = SampledMessages.fromRaw { 49 | cfg.getConfigList("samples") 50 | .map(x => Try(x.getDouble("percent")).toOption.map(_ / 100) -> payload(x)) 51 | .toList 52 | } 53 | 54 | val fm = Try(getFile(config)) 55 | val utf = fm.orElse(Try(getUTF(config))) 56 | val gen = utf.orElse(Try(getGenerated(config))) 57 | val samples = gen.orElse(Try(getSamples(config))) 58 | 59 | samples.getOrElse(GeneratedMessage(1024)) 60 | } 61 | 62 | def getConfig(conf: TSConfig): Config = { 63 | Config( 64 | conf.getString("host"), 65 | conf.getInt("port"), 66 | if (conf.hasPath("username")) Some(conf.getString("username")) else None, 67 | if (conf.hasPath("password")) Some(conf.getString("password")) else None, 68 | PublisherConfig( 69 | conf.getString("publishers.topic"), 70 | conf.getInt("publishers.count"), 71 | conf.getMilliseconds("publishers.millis-between-publish"), 72 | payload(conf.getConfig("publishers.payload")), 73 | conf.getInt("publishers.qos"), 74 | conf.getBoolean("publishers.retain"), 75 | conf.getString("publishers.client-id-prefix"), 76 | Try(conf.getBoolean("publishers.clean-session")).getOrElse(true), 77 | Try(conf.getInt("publishers.time-span")).toOption, 78 | Try(conf.getString("subscribers.validate-file")).getOrElse("sent-messages.dat") 79 | ), 80 | SubscriberConfig( 81 | conf.getString("subscribers.topic"), 82 | conf.getInt("subscribers.count"), 83 | conf.getInt("subscribers.qos"), 84 | conf.getString("subscribers.client-id-prefix"), 85 | Try(conf.getBoolean("subscribers.clean-session")).getOrElse(true), 86 | conf.getBoolean("subscribers.shared"), 87 | if (conf.hasPath("subscribers.custom-host")) Some(conf.getString("subscribers.custom-host")) else None, 88 | Try(conf.getInt("subscribers.time-span")).toOption, 89 | Try(conf.getString("subscribers.validate-file")).getOrElse("received-messages.dat") 90 | ), 91 | conf.getMilliseconds("millis-between-connects"), 92 | conf.getString("queue-monitor.clientid"), 93 | conf.getString("queue-monitor.topic"), 94 | Try(conf.getBoolean("pwNeedsHashing")).getOrElse(true), 95 | Try(conf.getBoolean("trace-latency")).getOrElse(false) 96 | ) 97 | } 98 | 99 | lazy val config = getConfig(configFactory) 100 | } 101 | 102 | case class PublisherConfig(topic: String, count: Int, rate: Long, payload: MessageSource, qos: Int, retain: Boolean, 103 | idPrefix: String, cleanSession: Boolean, timeSpan: Option[Int], validateFile: String) 104 | 105 | case class SubscriberConfig(topic: String, count: Int, qos: Int, clientIdPrefix: String, clean: Boolean, shared: Boolean, 106 | host: Option[String], timeSpan: Option[Int], validateFile: String) 107 | 108 | case class Config(host: String, port: Int, user: Option[String], password: Option[String], publishers: PublisherConfig, 109 | subscribers: SubscriberConfig, connectRate: Long, queueClientid: String, queueTopic: String, 110 | pwNeedsHashing: Boolean, traceLatency: Boolean) { 111 | 112 | 113 | private def templateTopic(topic: String, id: Int) = topic.replaceAll("\\$num", id.toString) 114 | 115 | def pubTopic(id: Int, msgId: Option[UUID]): String = { 116 | val msgIdSegment = msgId.map("/" + _.toString.replace("-", "")).getOrElse("") 117 | val topic = templateTopic(publishers.topic, id) + msgIdSegment 118 | topic 119 | } 120 | def subTopic(id: Int): String = { 121 | val msgSegment = 122 | if (traceLatency && !subscribers.topic.endsWith("#")) 123 | "/+" 124 | else 125 | "" 126 | templateTopic(subscribers.topic, id) + msgSegment 127 | } 128 | def subscriberId(id: Int) = subscribers.clientIdPrefix + id 129 | def publisherId(id: Int) = publishers.idPrefix + id 130 | 131 | /** 132 | * The password that is sent over the wire. If hashPassword is set to true, it md5's password 133 | * @return 134 | */ 135 | def wirePassword = 136 | if (!pwNeedsHashing) 137 | password 138 | else 139 | password.map(Client.md5) 140 | } 141 | 142 | sealed abstract class MessageSource { 143 | def get(clientNum: Int, iteration: Int): Array[Byte] 144 | } 145 | 146 | case class Utf8Message(msg: String) extends MessageSource { 147 | def get(clientNum: Int, iteration: Int) = msg.getBytes("utf8") 148 | } 149 | 150 | case class FileMessage(file: String) extends MessageSource { 151 | lazy val fileBytes = Streamable.bytes(new FileInputStream(file)) 152 | def get(clientNum: Int, iteration: Int) = fileBytes 153 | } 154 | 155 | case class GeneratedMessage(size: Int) extends MessageSource { 156 | lazy val msg: Array[Byte] = { 157 | val bytes = new Array[Byte](size) 158 | Random.nextBytes(bytes) 159 | bytes.map(b => ((b % 70) + 30).toByte) 160 | } 161 | 162 | def get(clientNum: Int, iteration: Int) = msg 163 | } 164 | 165 | case class Sample(msg: MessageSource, lower: Double, upper: Double) 166 | case class SampledMessages(samples: List[Sample]) extends MessageSource { 167 | def getMessage(n: Double) = samples 168 | .find(m => m.lower <= n && n < m.upper) 169 | .map(_.msg) 170 | .getOrElse(GeneratedMessage(1024)) 171 | 172 | def get(clientNum: Int, iteration: Int) = { 173 | val n = Random.nextDouble() 174 | getMessage(n).get(clientNum, iteration) 175 | } 176 | } 177 | 178 | object SampledMessages { 179 | def fromRaw(messages: List[(Option[Double], MessageSource)]): SampledMessages = { 180 | val (base, explicitSamples) = messages.filter(_._1.isDefined).foldLeft(0.0 -> List[Sample]()) { 181 | case ((low, acc), (percent, msg)) => 182 | val hi = low + percent.get 183 | hi -> (Sample(msg, low, hi) :: acc) 184 | } 185 | 186 | val remaining = messages.filter(!_._1.isDefined) 187 | val remainingPercent = 1 - base 188 | val size = remainingPercent / remaining.size 189 | 190 | val remainingSamples = remaining.map(_._2).zipWithIndex 191 | .map{ case (msg, i) => Sample(msg, base + (size*i), base + (size*i) + size)} 192 | 193 | SampledMessages(explicitSamples ++ remainingSamples) 194 | } 195 | } 196 | 197 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/LoadTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import org.joda.time.DateTime 23 | import java.security.MessageDigest 24 | import java.util.concurrent.atomic.{AtomicInteger, AtomicLong} 25 | import akka.actor._ 26 | import scala.concurrent.duration._ 27 | import org.fusesource.mqtt.client._ 28 | import scala.concurrent.ExecutionContext 29 | import org.fusesource.hawtbuf.{Buffer, UTF8Buffer} 30 | import org.fusesource.mqtt.codec.MQTTFrame 31 | import scala.util.Try 32 | import scala.collection.mutable 33 | import java.util.UUID 34 | import java.nio.ByteBuffer 35 | import io.m2m.kafka.KafkaConsumer 36 | 37 | object Client { 38 | import Config.config 39 | 40 | private def getClient(prefix: String, id: Int, clean: Boolean) = { 41 | val mqtt = new MQTT() 42 | mqtt.setTracer(new Tracer { 43 | override def onReceive(frame: MQTTFrame) { 44 | } 45 | }) 46 | mqtt.setHost(config.host, config.port) 47 | mqtt.setClientId(prefix + id) 48 | mqtt.setCleanSession(clean) 49 | if (config.user.isDefined) mqtt.setUserName(config.user.get) 50 | if (config.password.isDefined) mqtt.setPassword(config.wirePassword.get) 51 | mqtt.setKeepAlive(30) 52 | mqtt.callbackConnection() 53 | } 54 | 55 | def md5(str: String) = 56 | MessageDigest.getInstance("MD5").digest(str.getBytes("utf8")).map("%02x" format _).mkString 57 | 58 | def callback[T](success: T => Unit, failure: Throwable => Unit) = new Callback[T] { 59 | def onSuccess(item: T) = success(item) 60 | def onFailure(err: Throwable) = failure(err) 61 | } 62 | 63 | def qos(int: Int) = QoS.values().filter(q => q.ordinal() == int).head 64 | 65 | def subscribe(id: Int) = { 66 | val clean = config.subscribers.clean 67 | val client = getClient(config.subscribers.clientIdPrefix, id, clean).listener(new Listener { 68 | def onPublish(topic: UTF8Buffer, body: Buffer, ack: Runnable) { 69 | //println("Got data on "+ topic.toString) 70 | val buf = new Array[Byte](body.getLength) 71 | System.arraycopy(body.getData, body.getOffset, buf, 0, body.getLength) 72 | Reporter.messageArrived(topic.toString, buf) 73 | ack.run() 74 | } 75 | 76 | def onConnected() { 77 | Reporter.addSubscriber() 78 | } 79 | 80 | def onFailure(value: Throwable) { value.printStackTrace() } 81 | 82 | def onDisconnected() { 83 | Reporter.lostSubscriber() 84 | } 85 | }) 86 | 87 | client.connect(callback(_ => { 88 | client.subscribe(Array(new Topic(config.subTopic(id), qos(config.subscribers.qos))), 89 | callback(bytes => {}, _ => {})) 90 | LoadTest.subController ! Register(client) 91 | }, _ => {})) 92 | } 93 | 94 | def createPublisher(id: Int, actor: ActorRef) = { 95 | val client = getClient(config.publishers.idPrefix, id, config.publishers.cleanSession) 96 | client.listener(new Listener { 97 | def onPublish(topic: UTF8Buffer, body: Buffer, ack: Runnable) {} 98 | 99 | def onConnected() { 100 | Reporter.addPublisher() 101 | } 102 | 103 | def onFailure(value: Throwable) { value.printStackTrace() } 104 | 105 | def onDisconnected() { 106 | Reporter.lostPublisher() 107 | } 108 | }) 109 | client.connect(callback(_ => actor ! Start(client), _ => {})) 110 | } 111 | } 112 | 113 | 114 | class SubscriberController extends Actor { 115 | import Config.config 116 | import ExecutionContext.Implicits.global 117 | def receive = { 118 | case Register(client) => 119 | config.subscribers.timeSpan.foreach { case timeStamp => 120 | context.system.scheduler.scheduleOnce(timeStamp seconds, self, Stop(client)) 121 | } 122 | 123 | case Stop(client) => 124 | client.disconnect(null) 125 | } 126 | } 127 | 128 | case class Register(client: CallbackConnection) 129 | case class Start(client: CallbackConnection) 130 | case class Publish(client: CallbackConnection) 131 | case class Stop(client: CallbackConnection) 132 | 133 | case class Publisher(id: Int) extends Actor with LatencyTimer { 134 | import Config.config 135 | import ExecutionContext.Implicits.global 136 | 137 | val sleepBetweenPublishes = config.publishers.rate 138 | val qos = Client.qos(config.publishers.qos) 139 | def publishCallback = { 140 | val startNanos = System.nanoTime() 141 | Client.callback((_: Void) => Reporter.deliveryComplete(System.nanoTime() - startNanos), Reporter.messageErred) 142 | } 143 | var iteration = 0 144 | 145 | def receive = { 146 | case Start(client) => 147 | context.system.scheduler.schedule(0 millis, sleepBetweenPublishes millis) { 148 | Try(self ! Publish(client)).recover { 149 | case e: Throwable => e.printStackTrace() 150 | } 151 | } 152 | config.publishers.timeSpan.foreach(ts => { 153 | context.system.scheduler.scheduleOnce(ts seconds, self, Stop(client)) 154 | }) 155 | 156 | case Publish(client) => 157 | val payload = config.publishers.payload.get(id, iteration) 158 | 159 | val msgId = generateMessageId() 160 | val topic = config.pubTopic(id, msgId) 161 | client.publish(topic, payload, qos, config.publishers.retain, publishCallback) 162 | msgId match { 163 | case Some(id) => Reporter.sentPublish(id, topic, payload) 164 | case None => Reporter.sentPublish() 165 | } 166 | iteration += 1 167 | case Stop(client) => 168 | client.disconnect(null) 169 | self ! PoisonPill 170 | } 171 | } 172 | 173 | trait LatencyTimer { this: Actor => 174 | import Config.config 175 | 176 | def generateMessageId() = 177 | if (config.traceLatency) 178 | Some(getId()) 179 | else 180 | None 181 | 182 | @inline 183 | private def getId() = { 184 | val id = UUID.randomUUID() 185 | val ts = System.nanoTime() 186 | Reporter.addMessageId(id, ts) 187 | id 188 | } 189 | } 190 | 191 | object Reporter { 192 | import Config.config 193 | val start = DateTime.now().millisOfDay().get() 194 | val pubSent = new AtomicInteger() 195 | val pubComplete = new AtomicInteger() 196 | val pubAckTime = new AtomicLong() 197 | val subArrived = new AtomicInteger() 198 | val errors = new AtomicInteger() 199 | val latencyIds = new mutable.HashMap[UUID, Long]() 200 | val latencies = new AtomicLong() 201 | val latencyCount = new AtomicInteger() 202 | 203 | var lastTime = start 204 | var lastSent = 0 205 | var lastComplete = 0 206 | var lastAckTime = 0L 207 | var lastArrived = 0 208 | var lastErrors = 0 209 | var lastLatency = 0L 210 | var lastLatencyCount = 0 211 | 212 | def sentPublish(): Unit = pubSent.incrementAndGet() 213 | def sentPublish(id: UUID, topic: String, payload: Array[Byte]): Unit = { 214 | sentPublish() 215 | Validation.publisher ! Validation.Save(id, topic, payload) 216 | } 217 | def deliveryComplete(elapsedNanos: Long) = { 218 | pubAckTime.addAndGet(elapsedNanos) 219 | pubComplete.incrementAndGet() 220 | } 221 | def messageArrived(topic: String, payload: Array[Byte]) = { 222 | def string2uuid(uuid: String) = 223 | UUID.fromString(uuid.replaceAll("""(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})""", "$1-$2-$3-$4-$5")) 224 | 225 | subArrived.incrementAndGet() 226 | if (config.traceLatency) { 227 | val now = System.nanoTime() 228 | val uuid = topic.split('/').lastOption.map(x => Try(string2uuid(x)).toOption).flatten 229 | val start = uuid.map(messageLatency).flatten 230 | val diff = start.map(now - _) 231 | diff.foreach{ case delta => 232 | latencyCount.incrementAndGet() 233 | latencies.addAndGet(delta) 234 | } 235 | uuid.foreach(id => 236 | Validation.subscriber ! Validation.Save(id, topic, payload)) 237 | } 238 | } 239 | def connectionLost(error: Throwable) {error.printStackTrace()} 240 | def messageErred(error: Throwable) {errors.incrementAndGet()} 241 | 242 | var publishers = 0 243 | var subscribers = 0 244 | var lastReport: Option[Report] = None 245 | 246 | def addPublisher() {publishers += 1} 247 | def addSubscriber() {subscribers += 1} 248 | def lostPublisher() { publishers -= 1 } 249 | def lostSubscriber() { subscribers -= 1 } 250 | def addMessageId(id: UUID, nanos: Long) = latencyIds.synchronized { 251 | latencyIds += id -> nanos 252 | } 253 | def messageLatency(id: UUID) = latencyIds.synchronized(latencyIds.get(id)) 254 | 255 | def getReport(): Report = { 256 | val now = DateTime.now().millisOfDay().get() 257 | val sent = pubSent.get() 258 | val complete = pubComplete.get() 259 | val ackTime = pubAckTime.get() 260 | val arrived = subArrived.get() 261 | val currentErrors = errors.get() 262 | val latency = latencies.get() 263 | val latCount = latencyCount.get() 264 | 265 | val report = Report( 266 | now - start, 267 | sent - lastSent, 268 | complete - lastComplete, 269 | arrived - lastArrived, 270 | sent - complete, 271 | ((ackTime - lastAckTime).toDouble / (arrived - lastArrived)) / 1000000, 272 | currentErrors - lastErrors, 273 | publishers, 274 | subscribers, 275 | (latency - lastLatency).toDouble / (latCount - lastLatencyCount) / 1000000 276 | ) 277 | 278 | lastTime = now 279 | lastSent = sent 280 | lastComplete = complete 281 | lastAckTime = ackTime 282 | lastArrived = arrived 283 | lastErrors = currentErrors 284 | lastLatency = latency 285 | lastLatencyCount = latCount 286 | 287 | report 288 | } 289 | 290 | def doReport() { 291 | lastReport = Some(getReport()) 292 | if(lastReport.get.inFlight > 200000) { 293 | println("Inflight message count over 200K, something is wrong killing test") 294 | System.exit(1) 295 | } 296 | SplunkLogger.report2Splunk(lastReport.get) 297 | println(lastReport.get.csv) 298 | } 299 | } 300 | 301 | abstract class JsonSerialiazable { 302 | def json: String 303 | } 304 | 305 | case class Report(elapsedMs: Int, sentPs: Int, publishedPs: Int, consumedPs: Int, inFlight: Int, 306 | avgAckMillis: Double, errorsPs: Int, publishers: Int, subscribers: Int, latency: Double) extends JsonSerialiazable { 307 | import org.json4s.NoTypeHints 308 | import org.json4s.native.Serialization.{write, formats} 309 | 310 | implicit val fmts = formats(NoTypeHints) 311 | 312 | def csv = 313 | f"$elapsedMs,$sentPs,$publishedPs,$consumedPs,$inFlight,$avgAckMillis%.3f,$errorsPs,$publishers,$subscribers,$latency%.3f" 314 | 315 | def json = write(this) 316 | } 317 | 318 | object Report 319 | 320 | class Reporter extends Actor { 321 | println("Elapsed (ms),Sent (msgs/s),Published (msgs/s),Consumed (msgs/s),In Flight,Avg Ack (ms),Errors (msgs/s),Num Publishers,Num Subscribers,Avg Latency (ms)") 322 | 323 | def receive = { 324 | case Report => Reporter.doReport() 325 | } 326 | } 327 | 328 | object LoadTest extends App { 329 | 330 | //Don't cache DNS lookups 331 | java.security.Security.setProperty("networkaddress.cache.ttl" , "0") 332 | 333 | import Config.config 334 | import ExecutionContext.Implicits.global 335 | 336 | val system = ActorSystem("LoadTest") 337 | WebServer.start() 338 | val subController = system.actorOf(Props[SubscriberController]) 339 | 340 | val reporter = system.actorOf(Props[Reporter], "reporter") 341 | system.scheduler.schedule(1 second, 1 second) { 342 | reporter ! Report 343 | } 344 | 345 | //val queueReporting = system.actorOf(Props[QueueSubscriber].withDispatcher("subscribers.dispatcher"), s"queue-subscriber") 346 | //queueReporting ! Init 347 | 348 | val kafkaConfig = KafkaConsumer.KafkaConfig.get(Config.configFactory) 349 | kafkaConfig match { 350 | case Some(cfg) => 351 | println("Starting kafka consumers") 352 | KafkaConsumer.start(cfg) 353 | case None => 354 | println("Starting MQTT subscribers") 355 | for (i <- 1 to config.subscribers.count) { 356 | try { 357 | config.subscribers.shared match { 358 | case true => 359 | val subscriber = system.actorOf(Props[SharedSubscriber].withDispatcher("subscribers.dispatcher"), s"subscriber-$i") 360 | subscriber ! Init 361 | case false => Client.subscribe(i) 362 | } 363 | } catch { 364 | case e: Throwable => e.printStackTrace() 365 | } 366 | Thread.sleep(config.connectRate) 367 | } 368 | } 369 | 370 | for (i <- 1 to config.publishers.count) { 371 | try { 372 | val publisher = system.actorOf(Props(classOf[Publisher], i).withDispatcher("publishers.dispatcher"), s"publisher-$i") 373 | Client.createPublisher(i, publisher) 374 | } catch { 375 | case e: Throwable => e.printStackTrace() 376 | } 377 | Thread.sleep(config.connectRate) 378 | } 379 | 380 | } 381 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/QueueSubscriber.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import scala.collection.JavaConversions._ 23 | import net.sf.xenqtt.client.PublishMessage 24 | import akka.actor.Actor 25 | import net.sf.xenqtt.message.QoS 26 | import net.sf.xenqtt.client.AsyncMqttClient 27 | import net.sf.xenqtt.client.Subscription 28 | import org.json4s._ 29 | import org.json4s.NoTypeHints 30 | import org.json4s.native.JsonMethods._ 31 | import org.json4s.native.Serialization.{ write, formats } 32 | 33 | case class QueueMessage(ip_address: String, queue_id: String, queue_size: Int) extends JsonSerialiazable { 34 | 35 | implicit val fmts = formats(NoTypeHints) 36 | 37 | def json = write(this) 38 | } 39 | 40 | case class QueueReporter() extends ClientReporter { 41 | def reportLostConnection { 42 | 43 | } 44 | 45 | def reportNewConnection { 46 | 47 | } 48 | 49 | def reportMessageArrived(message: PublishMessage) { 50 | implicit val formats = DefaultFormats 51 | 52 | val json = parse(message.getPayloadString()) 53 | val report = json.extract[QueueMessage] 54 | SplunkLogger.report2Splunk(report) 55 | } 56 | } 57 | 58 | class QueueSubscriber extends Actor { 59 | val url = s"tcp://${Config.config.host}:${Config.config.port}" 60 | val username = Config.config.user.get 61 | val pw = Config.config.wirePassword.get 62 | val clientid = Config.config.queueClientid 63 | val subTopic = Config.config.queueTopic 64 | val subQos = QoS.AT_MOST_ONCE 65 | val clean = true 66 | 67 | val client = new AsyncMqttClient(url, new XenqttCallback(self, QueueReporter()), 10) 68 | val subscriptions = List(new Subscription(subTopic, subQos)) 69 | 70 | def receive = { 71 | case Init => client.connect(clientid, clean, username, pw) 72 | case Sub => client.subscribe(subscriptions) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/SharedSubscriber.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import scala.collection.JavaConversions._ 23 | import akka.actor.Actor 24 | import org.slf4j.LoggerFactory 25 | import net.sf.xenqtt.client._ 26 | import net.sf.xenqtt.message._ 27 | 28 | 29 | case class SubscribingReporter() extends ClientReporter { 30 | def reportLostConnection { 31 | Reporter.lostSubscriber() 32 | } 33 | 34 | def reportNewConnection { 35 | Reporter.addSubscriber() 36 | } 37 | 38 | def reportMessageArrived(message: PublishMessage) { 39 | Reporter.messageArrived(message.getTopic, message.getPayload) 40 | } 41 | } 42 | 43 | 44 | class SharedSubscriber extends Actor { 45 | implicit val logger = LoggerFactory.getLogger(classOf[SharedSubscriber]) 46 | 47 | val host = Config.config.subscribers.host.getOrElse(Config.config.host) 48 | val url = s"tcp://$host:${Config.config.port}" 49 | val username = Config.config.user.get 50 | val pw = Config.config.wirePassword.get 51 | val clientid = Config.config.subscribers.clientIdPrefix 52 | val subTopic = Config.config.subscribers.topic 53 | val subQos = Config.config.subscribers.qos 54 | val clean = Config.config.subscribers.clean 55 | 56 | val client = new AsyncMqttClient(url, new XenqttCallback(self, SubscribingReporter()), 50) 57 | val subscriptions = List(new Subscription(subTopic, QoS.AT_LEAST_ONCE)) 58 | 59 | def receive = { 60 | case Init => client.connect(clientid, clean, username, pw) 61 | case Sub => client.subscribe(subscriptions) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/SplunkLogger.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import dispatch._ 23 | import scala.concurrent.ExecutionContext 24 | import ExecutionContext.Implicits.global 25 | import scala.util.Try 26 | 27 | object SplunkLogger { 28 | 29 | def report2Splunk(report: JsonSerialiazable) = Config.splunkConf match { 30 | case Some(conf) => Try { 31 | val params = Map("index" -> conf.splunkProject, "sourcetype" -> "json_no_timestamp") 32 | val request = url(conf.splunkUrl).as_!(conf.splunkUser, conf.splunkPass) << report.json < 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/Validation.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import akka.actor.{Props, Actor} 23 | import java.util.UUID 24 | import java.io._ 25 | import java.nio.ByteBuffer 26 | import scala.util.{Failure, Success, Try} 27 | import scala.annotation.tailrec 28 | import java.util.zip.CRC32 29 | import java.nio.channels.FileChannel 30 | 31 | object Validation extends Validator { 32 | import LoadTest.system 33 | import Config.config 34 | 35 | case class Save(id: UUID, topic: String, payload: Array[Byte]) 36 | 37 | Runtime.getRuntime.addShutdownHook(new Thread { 38 | override def run() { 39 | LoadTest.system.shutdown() 40 | pubStream.flush() 41 | pubStream.close() 42 | subStream.flush() 43 | subStream.close() 44 | println() 45 | println(s"Processing final report...") 46 | validate(config.publishers.validateFile, config.subscribers.validateFile) 47 | } 48 | }) 49 | 50 | lazy val pubStream = new FileOutputStream(config.publishers.validateFile, false) 51 | lazy val publisher = system.actorOf(Props(classOf[Validation], pubStream)) 52 | lazy val subStream = new FileOutputStream(config.subscribers.validateFile, false) 53 | lazy val subscriber = system.actorOf(Props(classOf[Validation], subStream)) 54 | } 55 | 56 | class Validation(stream: FileOutputStream) extends Actor { 57 | import Validation._ 58 | 59 | val chan = stream.getChannel 60 | 61 | def receive = { 62 | case Save(id, topic, payload) => 63 | val topicBytes = topic.getBytes 64 | val length = 16 + 4 + topicBytes.size + 8 65 | val buffer = ByteBuffer.allocate(length) 66 | 67 | buffer.putLong(id.getMostSignificantBits) 68 | buffer.putLong(id.getLeastSignificantBits) 69 | buffer.putInt(topicBytes.length) 70 | buffer.put(topicBytes) 71 | buffer.putLong(crc(payload)) 72 | 73 | buffer.flip() 74 | 75 | chan.write(buffer) 76 | } 77 | } 78 | 79 | trait Validator { 80 | def validate(publishFile: String, subscribeFile: String) = { 81 | val published = read(publishFile).toMap 82 | val received = read(subscribeFile) 83 | var receivedIds = Set[UUID]() 84 | var notSent = 0 85 | var notValid = 0 86 | var valid = 0 87 | 88 | for ((id, (topic, crc)) <- received) { 89 | published.get(id) match { 90 | case None => notSent += 1 91 | case Some((pubTopic, pubCrc)) => 92 | if (topic != pubTopic || crc != pubCrc) 93 | notValid += 1 94 | else 95 | valid += 1 96 | } 97 | 98 | receivedIds += id 99 | } 100 | 101 | val notReceived = published.map(_._1).count(!receivedIds(_)) 102 | 103 | println() 104 | println(s"======== REPORT ============") 105 | if (notSent > 0) { 106 | println(s"WARNING: Received but not sent: $notSent") 107 | } 108 | println(s"Corrupted: $notValid") 109 | println(s"Not Received: $notReceived") 110 | println(s"Valid: $valid") 111 | } 112 | 113 | def crc(buffer: Array[Byte]) = { 114 | val crc = new CRC32() 115 | crc.update(buffer) 116 | crc.getValue 117 | } 118 | 119 | def read(file: String) = { 120 | val stream = new DataInputStream(new FileInputStream(file)) 121 | 122 | def readString(count: Int) = { 123 | val buffer = new Array[Byte](count) 124 | stream.read(buffer) 125 | new String(buffer) 126 | } 127 | 128 | @tailrec 129 | def loop(acc: List[(UUID, (String, Long))]): List[(UUID, (String, Long))] = Try { 130 | val id = { 131 | val msb = stream.readLong() 132 | val lsb = stream.readLong() 133 | new UUID(msb, lsb) 134 | } 135 | val topicLength = stream.readInt() 136 | val topic = readString(topicLength) 137 | val payload = stream.readLong() 138 | 139 | id -> (topic, payload) 140 | } match { 141 | case Success(pair) => loop(pair :: acc) 142 | case Failure(_) => acc 143 | } 144 | 145 | val map = loop(Nil) 146 | stream.close() 147 | map 148 | } 149 | } 150 | 151 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/WebServer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import org.mashupbots.socko.routes.{Path, HttpRequest, GET, Routes} 23 | import akka.actor.{ActorLogging, Actor, Props} 24 | import org.mashupbots.socko.events.HttpRequestEvent 25 | import org.mashupbots.socko.webserver 26 | import org.mashupbots.socko.webserver.WebServerConfig 27 | 28 | object WebServer { 29 | import LoadTest.system 30 | 31 | val webServer = system.actorOf(Props[WebServer]) 32 | 33 | val routes = Routes { 34 | case HttpRequest(request) => request match { 35 | case (GET(Path("/current"))) => 36 | webServer ! Current(request) 37 | } 38 | } 39 | 40 | val akkaConfig = new WebServerConfig(system.settings.config, "http") 41 | val server = new webserver.WebServer(akkaConfig, routes, system) 42 | println(akkaConfig.hostname) 43 | 44 | def enabled = 45 | if (system.settings.config.hasPath("http.enabled")) 46 | system.settings.config.getBoolean("http.enabled") 47 | else 48 | true 49 | 50 | def start() { 51 | if (!enabled) return 52 | 53 | server.start() 54 | 55 | Runtime.getRuntime.addShutdownHook(new Thread() { 56 | override def run() { server.stop() } 57 | }) 58 | } 59 | } 60 | 61 | case class Current(request: HttpRequestEvent) 62 | 63 | class WebServer extends Actor with ActorLogging { 64 | def receive = { 65 | case Current(request) => 66 | request.response.write(Reporter.lastReport.map(_.json).getOrElse("{}")) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/XenPublisher.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import akka.actor.Actor 23 | import scala.collection.JavaConversions._ 24 | import net.sf.xenqtt.client.AsyncClientListener 25 | import net.sf.xenqtt.client.AsyncMqttClient 26 | import net.sf.xenqtt.client.Subscription 27 | import net.sf.xenqtt.message.QoS 28 | import scala.concurrent.duration._ 29 | import scala.concurrent.ExecutionContext 30 | import ExecutionContext.Implicits.global 31 | import net.sf.xenqtt.client.PublishMessage 32 | 33 | case class Connect(id: Int) 34 | case class Pub(id: Int) 35 | 36 | class PubReporter() extends ClientReporter { 37 | 38 | def reportLostConnection = { 39 | Reporter.lostPublisher() 40 | } 41 | 42 | def reportNewConnection = { 43 | Reporter.addPublisher() 44 | } 45 | 46 | def reportMessageArrived(message: PublishMessage) { 47 | 48 | } 49 | } 50 | 51 | class XenPublisher(client: AsyncMqttClient) extends Actor with LatencyTimer { 52 | 53 | val config = Config.config 54 | val url = s"tcp://${Config.config.host}:${Config.config.port}" 55 | val username = config.user.get 56 | val pw = Config.config.wirePassword.get 57 | val clean = config.publishers.cleanSession 58 | val sleepBetweenPublishes = config.publishers.rate 59 | val pubPrefix = config.publishers.idPrefix 60 | 61 | var iteration = 0 62 | 63 | def receive = { 64 | case Connect(id) => init(id) 65 | case Pub(id) => publish(id) 66 | } 67 | 68 | def init(id: Int) = { 69 | client.connect(pubPrefix + id, clean, username, pw) 70 | context.system.scheduler.schedule(1000 millis, sleepBetweenPublishes millis) { 71 | self ! Pub(id) 72 | } 73 | } 74 | 75 | def publish(id: Int) = { 76 | val payload = Config.config.publishers.payload.get(id, iteration) 77 | val msgId = generateMessageId() 78 | val topic = config.pubTopic(id, msgId) 79 | client.publish(new PublishMessage(topic, QoS.AT_LEAST_ONCE, payload)) 80 | msgId match { 81 | case Some(uuid) => Reporter.sentPublish(uuid, topic, payload) 82 | case None => Reporter.sentPublish() 83 | } 84 | iteration += 1 85 | } 86 | 87 | def reportLostConnection = { 88 | Reporter.lostPublisher() 89 | } 90 | 91 | def reportNewConnection = { 92 | Reporter.addPublisher() 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/main/scala/io/m2m/mqtt/XenqttCallback.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import net.sf.xenqtt.client._ 23 | import net.sf.xenqtt.message._ 24 | import org.slf4j.LoggerFactory 25 | import akka.actor.ActorRef 26 | 27 | 28 | trait ClientReporter { 29 | def reportLostConnection 30 | def reportNewConnection 31 | def reportMessageArrived(message: PublishMessage) 32 | } 33 | 34 | case object Init 35 | case object Sub 36 | 37 | class XenqttCallback(ref: ActorRef, reporter: ClientReporter) extends AsyncClientListener { 38 | implicit val logger = LoggerFactory.getLogger(classOf[XenqttCallback]) 39 | 40 | 41 | override def publishReceived(client: MqttClient, message: PublishMessage) = { 42 | reporter.reportMessageArrived(message) 43 | message.ack 44 | } 45 | 46 | override def disconnected(client: MqttClient, cause: Throwable, reconnecting: Boolean) = { 47 | reporter.reportLostConnection 48 | Option(cause) match { 49 | case Some(ex) => logger.error("Disconnected Exception", ex) 50 | case None => logger.info("Got Disconneted Unknown") 51 | } 52 | 53 | reconnecting match { 54 | case true => logger.info("Attempting to reconnect") 55 | case false => 56 | } 57 | } 58 | 59 | override def connected(client: MqttClient, returnCode: ConnectReturnCode) = { 60 | returnCode match { 61 | case rc if rc != ConnectReturnCode.ACCEPTED => println("Unable to connect to the broker. Reason: " + rc) 62 | case _ => 63 | reporter.reportNewConnection 64 | ref ! Sub 65 | } 66 | } 67 | 68 | override def published(client: MqttClient, message: PublishMessage) = { 69 | Reporter.deliveryComplete(0L) 70 | } 71 | 72 | override def subscribed(client: MqttClient, requestedSubscriptions: Array[Subscription], 73 | grantedSubscriptions: Array[Subscription], requestsGranted: Boolean) = requestsGranted match { 74 | case false => logger.error("Unable to subscribe to the following subscriptions: " + requestedSubscriptions.deep.mkString("\n")) 75 | case true => logger.info("Granted subscriptions: " + grantedSubscriptions.deep.mkString("\n")) 76 | } 77 | 78 | override def unsubscribed(client: MqttClient, topics: Array[String]) = { 79 | 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/test/scala/io/m2m/mqtt/ConfigSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 2lemetry, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | 19 | 20 | package io.m2m.mqtt 21 | 22 | import com.typesafe.config.{ConfigValue, ConfigFactory, Config => TSConfig} 23 | import org.scalatest.{Inside, FunSpec} 24 | 25 | class ConfigSpec extends FunSpec with Inside { 26 | 27 | describe("SampledMessages.fromRaw") { 28 | it("processes multiple messages") { 29 | val m = GeneratedMessage(45) 30 | val result = SampledMessages.fromRaw(List(Some(0.4) -> m, Some(0.6) -> m)) 31 | 32 | assert(result.samples.toSet === Set(Sample(m, 0.0, 0.4), Sample(m, 0.4, 1.0))) 33 | } 34 | 35 | it("defaults None to equal portions") { 36 | val m = GeneratedMessage(45) 37 | val result = SampledMessages.fromRaw(List(None -> m, None -> m, None -> m, None -> m)) 38 | 39 | assert(result.samples.toSet === Set(Sample(m, 0, 0.25), Sample(m, 0.25, 0.5), Sample(m, 0.5, 0.75), Sample(m, 0.75, 1.0))) 40 | } 41 | 42 | it("divides the remaining to equal portions") { 43 | val m = GeneratedMessage(45) 44 | val result = SampledMessages.fromRaw(List(None -> m, Some(0.25) -> m, None -> m, Some(0.25) -> m)) 45 | 46 | assert(result.samples.toSet === Set(Sample(m, 0, 0.25), Sample(m, 0.25, 0.5), Sample(m, 0.5, 0.75), Sample(m, 0.75, 1.0))) 47 | } 48 | } 49 | 50 | describe("payload") { 51 | it("processes multiple messages correctly") { 52 | val cfg = ConfigFactory.parseString( 53 | """ 54 | |samples = [ 55 | | { 56 | | percent = 25 57 | | file = publish.json 58 | | }, 59 | | { 60 | | size = 25 61 | | percent = 25 62 | | }, 63 | | { 64 | | text = hello world 65 | | } 66 | |] 67 | """.stripMargin) 68 | 69 | inside(Config.payload(cfg)) { 70 | case SampledMessages(s) => 71 | val samples = s.toSet 72 | assert(samples === 73 | Set(Sample(GeneratedMessage(25), 0.25, 0.5), 74 | Sample(FileMessage("publish.json"), 0.0, 0.25), 75 | Sample(Utf8Message("hello world"), 0.5, 1.0) 76 | )) 77 | } 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /startLoad.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | printf "Ulimit before: %s\n" "`ulimit -n`" 4 | ulimit -n 256000 || { echo "Failed to set ulimit, check the Upstart script and limits.conf."; exit 1; } 5 | printf "Ulimit after: %s\n" "`ulimit -n`" 6 | 7 | export JAVA_OPTS="-Xms512m -Xmx3024m -XX:MaxPermSize=256m" 8 | 9 | echo $JAVA_OPTS 10 | 11 | target/start --------------------------------------------------------------------------------