mutalisk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +3 -0
- data/README.md +79 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/mutalisk +5 -0
- data/bin/setup +7 -0
- data/lib/mutalisk.rb +10 -0
- data/lib/mutalisk/ae.rb +22 -0
- data/lib/mutalisk/cli.rb +155 -0
- data/lib/mutalisk/connection.rb +124 -0
- data/lib/mutalisk/errors.rb +6 -0
- data/lib/mutalisk/request.rb +66 -0
- data/lib/mutalisk/response.rb +56 -0
- data/lib/mutalisk/runner.rb +75 -0
- data/lib/mutalisk/runner_thread.rb +94 -0
- data/lib/mutalisk/stats.rb +22 -0
- data/lib/mutalisk/version.rb +3 -0
- data/mutalisk.gemspec +36 -0
- metadata +163 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d5a04080381afb1c9f0aaa2339636135dda6bbb0
|
4
|
+
data.tar.gz: 3840193f635d2110303b9ff00fa73d408e70f63d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d3624024cb5aad3a074e039b701c6dfca3733870a36aa0a083560e2fae3fe0ee23f42d796642f4779397776cc671f24f12d6df4720ba94bcea66ae20fff9d5b4
|
7
|
+
data.tar.gz: 5f8a8ced9370289f4761c2c68214ccf5315db92e726c981711443057b00bb52537f6600f1d16b782aaf8d76e431f688b7799425d18cd7ea11f0fc1003972664b
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# Mutalisk
|
2
|
+
|
3
|
+
[Mutalisk](http://starcraft.wikia.com/wiki/Mutalisk_(StarCraft)) is a simple yet powerful HTTP API benchmarking tool.
|
4
|
+
|
5
|
+
Mutalisk emulates [wrk](https://github.com/wg/wrk), combines the power of multi-threading and IO-multiplexing.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'mutalisk'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install mutalisk
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
Do it in [wrk](https://github.com/wg/wrk) style:
|
26
|
+
|
27
|
+
$ mutalisk -d 5 -t 2 -c 50 --latency http://v2.same.com/channel/1070566/senses
|
28
|
+
|
29
|
+
Sample output:
|
30
|
+
|
31
|
+
```
|
32
|
+
Running 5s test @ http://v2.same.com/channel/1070566/senses
|
33
|
+
2 threads and 50 connections
|
34
|
+
+----------+------------+---------------+--------------------+------------------------------------------------+
|
35
|
+
| time | bytes_read | requests_sent | responses_received | errors |
|
36
|
+
+----------+------------+---------------+--------------------+------------------------------------------------+
|
37
|
+
| 5.012713 | 27876713 | 1225 | 1175 | {:read=>0, :write=>0, :connect=>0, :status=>0} |
|
38
|
+
+----------+------------+---------------+--------------------+------------------------------------------------+
|
39
|
+
Latency Distribution
|
40
|
+
50% 159.21ms
|
41
|
+
75% 217.85ms
|
42
|
+
90% 280.15ms
|
43
|
+
95% 344.61ms
|
44
|
+
99% 523.09ms
|
45
|
+
1175 requests in 5.01s, 26.58MB read
|
46
|
+
Requests/sec: 234.40
|
47
|
+
Transfer/sec: 5.30MB
|
48
|
+
```
|
49
|
+
|
50
|
+
Side by side comparison with `wrk`:
|
51
|
+
|
52
|
+
$ wrk -d 5 -t 2 -c 50 --latency http://v2.same.com/channel/1070566/senses
|
53
|
+
|
54
|
+
```
|
55
|
+
Running 5s test @ http://v2.same.com/channel/1070566/senses
|
56
|
+
2 threads and 50 connections
|
57
|
+
Thread Stats Avg Stdev Max +/- Stdev
|
58
|
+
Latency 238.27ms 370.89ms 2.07s 96.00%
|
59
|
+
Req/Sec 131.30 9.78 142.00 70.00%
|
60
|
+
Latency Distribution
|
61
|
+
50% 154.93ms
|
62
|
+
75% 197.59ms
|
63
|
+
90% 321.57ms
|
64
|
+
99% 2.07s
|
65
|
+
1241 requests in 5.01s, 28.00MB read
|
66
|
+
Requests/sec: 247.51
|
67
|
+
Transfer/sec: 5.58MB
|
68
|
+
```
|
69
|
+
|
70
|
+
## Development
|
71
|
+
|
72
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
73
|
+
|
74
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
75
|
+
|
76
|
+
## Contributing
|
77
|
+
|
78
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/forresty/mutalisk.
|
79
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "mutalisk"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/mutalisk
ADDED
data/bin/setup
ADDED
data/lib/mutalisk.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
require_relative 'mutalisk/version'
|
2
|
+
require_relative 'mutalisk/errors'
|
3
|
+
require_relative 'mutalisk/stats'
|
4
|
+
require_relative 'mutalisk/connection'
|
5
|
+
require_relative 'mutalisk/ae'
|
6
|
+
require_relative 'mutalisk/request'
|
7
|
+
require_relative 'mutalisk/response'
|
8
|
+
require_relative 'mutalisk/runner_thread'
|
9
|
+
require_relative 'mutalisk/runner'
|
10
|
+
require_relative 'mutalisk/cli'
|
data/lib/mutalisk/ae.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
module Mutalisk
|
2
|
+
class AE
|
3
|
+
SELECT_TIMEOUT = 0.01
|
4
|
+
|
5
|
+
def self.readable(connections, pipe)
|
6
|
+
sockets = connections.map(&:socket)
|
7
|
+
|
8
|
+
readable, _ = IO.select(sockets + [pipe], nil, nil, SELECT_TIMEOUT)
|
9
|
+
|
10
|
+
if readable
|
11
|
+
readable.map do |io|
|
12
|
+
if io == pipe
|
13
|
+
pipe
|
14
|
+
else
|
15
|
+
socket = io
|
16
|
+
socket.instance_variable_get(:@_mutalisk_connection)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/mutalisk/cli.rb
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'addressable/uri'
|
3
|
+
|
4
|
+
module Mutalisk
|
5
|
+
class CLI
|
6
|
+
def initialize(args)
|
7
|
+
@args = args
|
8
|
+
end
|
9
|
+
|
10
|
+
def start
|
11
|
+
Stats.reset
|
12
|
+
@options = { headers: {} }
|
13
|
+
|
14
|
+
parser = OptionParser.new do |opts|
|
15
|
+
opts.banner = "Usage: mutalisk <options> <url>\n Options:"
|
16
|
+
opts.version = Mutalisk::VERSION
|
17
|
+
|
18
|
+
opts.on("-c", "--connections N", Integer, "<N> Connections to keep open") do |n|
|
19
|
+
@options[:connections] = n
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on("-d", "--duration T", Integer, "<T> Duration of test") do |t|
|
23
|
+
@options[:seconds] = t
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on("-t", "--threads N", Integer, "<N> Number of threads to use") do |n|
|
27
|
+
@options[:threads] = n
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on('-H', '--header H', String, "<H> Add header to request") do |h|
|
31
|
+
if h =~ /^(.+): (.+)$/
|
32
|
+
@options[:headers][$1] = $2
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
opts.on("--latency", " Print latency statistics") do |l|
|
37
|
+
@options[:print_latency] = l
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.on("-v", "--[no-]verbose", " Run verbosely") do |v|
|
41
|
+
@options[:verbose] = v
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.on("-h", "--help", " Prints this help") do
|
45
|
+
puts opts
|
46
|
+
exit
|
47
|
+
end
|
48
|
+
end
|
49
|
+
parser.parse!(@args)
|
50
|
+
|
51
|
+
if url = @args.shift
|
52
|
+
uri = Addressable::URI.parse(url)
|
53
|
+
if uri.scheme =~ /^https?/
|
54
|
+
@options[:uri] = uri
|
55
|
+
|
56
|
+
runner = Runner.new(@options)
|
57
|
+
print_header(runner)
|
58
|
+
runner.start
|
59
|
+
print_results(runner)
|
60
|
+
else
|
61
|
+
puts "invalid URL: `#{url}', only HTTP/HTTPS endpoints are supported at the moment"
|
62
|
+
end
|
63
|
+
else
|
64
|
+
puts parser
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def print_header(runner)
|
71
|
+
seconds, uri, threads, connections = runner.options.values_at(:seconds, :uri, :threads, :connections)
|
72
|
+
|
73
|
+
puts "Running #{seconds}s test @ #{uri}"
|
74
|
+
puts " #{threads} threads and #{connections} connections"
|
75
|
+
end
|
76
|
+
|
77
|
+
def print_errors(errors)
|
78
|
+
read, write, connect, status = errors.values_at(:read, :write, :connect, :status)
|
79
|
+
|
80
|
+
if read > 0 || write > 0 || connect > 0
|
81
|
+
puts " Socket errors: connect #{connect}, read #{read}, write #{write}, timeout UNKNOWN"
|
82
|
+
end
|
83
|
+
|
84
|
+
if status > 0
|
85
|
+
puts " Non-2xx or 3xx responses: #{status}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def print_results(runner)
|
90
|
+
require 'ttable'
|
91
|
+
puts Terminal::Table.new(runner.results)
|
92
|
+
completed_requests = runner.results[:responses_received]
|
93
|
+
total_time = runner.results[:time]
|
94
|
+
bytes_read = runner.results[:bytes_read]
|
95
|
+
|
96
|
+
if @options[:print_latency]
|
97
|
+
puts " Latency Distribution"
|
98
|
+
[50, 75, 90, 95, 99].each do |pc|
|
99
|
+
puts " #{pc}% #{pretty_time(Stats.percentile(pc))}"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
puts " #{completed_requests} requests in #{'%.2f' % total_time}s, #{pretty_bytes(bytes_read)} read"
|
104
|
+
|
105
|
+
qps = '%.2f' % (completed_requests.to_f / total_time)
|
106
|
+
bps = pretty_bytes(bytes_read / total_time)
|
107
|
+
|
108
|
+
print_errors(runner.results[:errors])
|
109
|
+
|
110
|
+
puts "Requests/sec: #{qps.rjust(8)}"
|
111
|
+
puts "Transfer/sec: #{bps.rjust(10)}"
|
112
|
+
|
113
|
+
if @options[:verbose]
|
114
|
+
puts "All finished requests:"
|
115
|
+
puts
|
116
|
+
puts Terminal::Table.new(Stats.metrics)
|
117
|
+
end
|
118
|
+
|
119
|
+
@results = {
|
120
|
+
qps: qps,
|
121
|
+
tps: bps,
|
122
|
+
uri: @options[:uri]
|
123
|
+
}
|
124
|
+
end
|
125
|
+
|
126
|
+
def pretty_time(sec)
|
127
|
+
if sec > 1
|
128
|
+
('%.2fs' % sec).rjust(9)
|
129
|
+
else
|
130
|
+
('%.2fms' % (sec * 1000)).rjust(10)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def pretty_bytes(bytes)
|
135
|
+
kb, bytes = bytes.divmod(1024)
|
136
|
+
|
137
|
+
if kb > 0
|
138
|
+
mb, kb = kb.divmod(1024)
|
139
|
+
|
140
|
+
if mb > 0
|
141
|
+
"#{'%.2f' % ((mb*1024 + kb)/1024.0)}MB"
|
142
|
+
else
|
143
|
+
"#{'%.2f' % ((kb*1024 + bytes)/1024.0)}KB"
|
144
|
+
end
|
145
|
+
|
146
|
+
else
|
147
|
+
"#{bytes} bytes"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.start(args)
|
152
|
+
new(args).start
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module Mutalisk
|
4
|
+
class Connection
|
5
|
+
attr_reader :socket
|
6
|
+
attr_reader :uri
|
7
|
+
attr_reader :connection_id
|
8
|
+
attr_reader :thread
|
9
|
+
|
10
|
+
# stats
|
11
|
+
attr_reader :bytes_read
|
12
|
+
attr_reader :requests_sent
|
13
|
+
attr_reader :responses_received
|
14
|
+
attr_reader :read_errors
|
15
|
+
attr_reader :write_errors
|
16
|
+
attr_reader :connect_errors
|
17
|
+
|
18
|
+
READ_MAXLEN = 100_000
|
19
|
+
|
20
|
+
def initialize(thread, uri, cid)
|
21
|
+
@thread = thread
|
22
|
+
@uri = uri
|
23
|
+
@connection_id = cid
|
24
|
+
|
25
|
+
# stats
|
26
|
+
@bytes_read = 0
|
27
|
+
@requests_sent = 0
|
28
|
+
@responses_received = 0
|
29
|
+
@read_errors = 0
|
30
|
+
@write_errors = 0
|
31
|
+
@connect_errors = 0
|
32
|
+
|
33
|
+
connect
|
34
|
+
end
|
35
|
+
|
36
|
+
def idle?
|
37
|
+
!!@idle
|
38
|
+
end
|
39
|
+
|
40
|
+
def send_request(headers=nil)
|
41
|
+
raise ConnectionBusyError unless idle?
|
42
|
+
|
43
|
+
reset_response
|
44
|
+
|
45
|
+
@request = spawn_request(headers)
|
46
|
+
@request.start
|
47
|
+
|
48
|
+
@idle = false
|
49
|
+
@requests_sent += 1
|
50
|
+
|
51
|
+
@request
|
52
|
+
rescue Errno::EPIPE
|
53
|
+
@write_errors += 1
|
54
|
+
reconnect
|
55
|
+
end
|
56
|
+
|
57
|
+
def receive
|
58
|
+
@response ||= Response.new(@request)
|
59
|
+
|
60
|
+
resp = @socket.readpartial(READ_MAXLEN)
|
61
|
+
|
62
|
+
@bytes_read += resp.bytesize
|
63
|
+
|
64
|
+
@response << resp
|
65
|
+
|
66
|
+
if @response.complete?
|
67
|
+
@idle = true
|
68
|
+
@responses_received += 1
|
69
|
+
end
|
70
|
+
|
71
|
+
@response
|
72
|
+
rescue EOFError
|
73
|
+
@idle = true
|
74
|
+
@response
|
75
|
+
rescue Errno::ECONNRESET => e
|
76
|
+
@read_errors += 1
|
77
|
+
@idle = true
|
78
|
+
reconnect
|
79
|
+
|
80
|
+
@response
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def spawn_request(headers)
|
86
|
+
@rid ||= 0
|
87
|
+
request = Request.new(self, @rid, headers)
|
88
|
+
@rid += 1
|
89
|
+
|
90
|
+
request
|
91
|
+
end
|
92
|
+
|
93
|
+
def connect
|
94
|
+
reconnect
|
95
|
+
end
|
96
|
+
|
97
|
+
def reconnect
|
98
|
+
if uri.scheme == 'https'
|
99
|
+
sock = TCPSocket.new(uri.host, uri.port || 443)
|
100
|
+
|
101
|
+
require 'openssl'
|
102
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
103
|
+
|
104
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
105
|
+
|
106
|
+
@socket = OpenSSL::SSL::SSLSocket.new(sock, ctx).tap do |socket|
|
107
|
+
socket.sync_close = true
|
108
|
+
socket.connect
|
109
|
+
end
|
110
|
+
else
|
111
|
+
@socket = TCPSocket.new(uri.host, uri.port || 80)
|
112
|
+
end
|
113
|
+
|
114
|
+
@socket.instance_variable_set(:@_mutalisk_connection, self)
|
115
|
+
@idle = true
|
116
|
+
rescue SocketError
|
117
|
+
@connect_errors += 1
|
118
|
+
end
|
119
|
+
|
120
|
+
def reset_response
|
121
|
+
@response = nil
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Mutalisk
|
2
|
+
class Request
|
3
|
+
attr_accessor :uri
|
4
|
+
attr_accessor :connection
|
5
|
+
attr_accessor :request_id
|
6
|
+
attr_accessor :t0
|
7
|
+
|
8
|
+
def initialize(connection, rid, headers=nil)
|
9
|
+
@connection = connection
|
10
|
+
@uri = @connection.uri
|
11
|
+
@socket = @connection.socket
|
12
|
+
@request_id = rid
|
13
|
+
@headers = headers
|
14
|
+
@done = false
|
15
|
+
end
|
16
|
+
|
17
|
+
def start
|
18
|
+
@t0 = Time.now
|
19
|
+
path = @uri.path && !@uri.path.empty? ? @uri.path : '/'
|
20
|
+
|
21
|
+
header_lines = [
|
22
|
+
"GET #{path} HTTP/1.1",
|
23
|
+
# 'Accept: */*',
|
24
|
+
# 'Accept-Encoding: gzip, deflate',
|
25
|
+
# 'Connection: keep-alive',
|
26
|
+
"Host: #{@uri.host}",
|
27
|
+
# "User-Agent: Mutalisk/#{Mutalisk::VERSION}"
|
28
|
+
]
|
29
|
+
|
30
|
+
if @headers
|
31
|
+
@headers.each do |k, v|
|
32
|
+
header_lines << "#{k}: #{v}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
headers = header_lines.join("\r\n") + "\r\n\r\n"
|
37
|
+
|
38
|
+
bytes_written = @socket.write(headers)
|
39
|
+
|
40
|
+
# assume out-going buffer is large enough
|
41
|
+
assert bytes_written == headers.bytesize
|
42
|
+
|
43
|
+
@done = true
|
44
|
+
rescue IO::EAGAINWaitWritable
|
45
|
+
retry
|
46
|
+
end
|
47
|
+
|
48
|
+
def done?
|
49
|
+
!!@done
|
50
|
+
end
|
51
|
+
|
52
|
+
def req_id
|
53
|
+
tid = connection.thread.thread_id
|
54
|
+
cid = connection.connection_id
|
55
|
+
rid = request_id
|
56
|
+
|
57
|
+
"t#{tid}-c#{cid}-r#{rid}"
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def assert(condition)
|
63
|
+
raise AssertionFailedError unless condition
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'http/parser'
|
2
|
+
|
3
|
+
module Mutalisk
|
4
|
+
class Response
|
5
|
+
attr_reader :code
|
6
|
+
attr_reader :headers
|
7
|
+
attr_reader :body
|
8
|
+
attr_reader :request
|
9
|
+
attr_reader :time_spent
|
10
|
+
|
11
|
+
def initialize(request)
|
12
|
+
@request = request
|
13
|
+
|
14
|
+
@completed = false
|
15
|
+
@code = nil
|
16
|
+
@body = ''
|
17
|
+
@time_spent = nil
|
18
|
+
|
19
|
+
@parser = Http::Parser.new
|
20
|
+
|
21
|
+
@parser.on_headers_complete = proc do
|
22
|
+
@code = @parser.status_code
|
23
|
+
end
|
24
|
+
|
25
|
+
@parser.on_body = proc do |chunk|
|
26
|
+
@body << chunk
|
27
|
+
end
|
28
|
+
|
29
|
+
@parser.on_message_complete = proc do
|
30
|
+
@time_spent = Time.now - @request.t0
|
31
|
+
@completed = true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def <<(data)
|
36
|
+
@parser << data
|
37
|
+
end
|
38
|
+
|
39
|
+
def complete?
|
40
|
+
!!@completed
|
41
|
+
end
|
42
|
+
|
43
|
+
def success?
|
44
|
+
complete? && (@code >= 200 && @code <= 399)
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_h
|
48
|
+
{
|
49
|
+
req_id: @request.req_id,
|
50
|
+
code: @code,
|
51
|
+
body_size: @body.bytesize,
|
52
|
+
time_spent: @time_spent
|
53
|
+
}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Mutalisk
|
2
|
+
class Runner
|
3
|
+
attr_reader :results
|
4
|
+
attr_reader :options
|
5
|
+
|
6
|
+
DEFAULT_OPTIONS = {
|
7
|
+
connections: 10,
|
8
|
+
seconds: 10,
|
9
|
+
threads: 2
|
10
|
+
}
|
11
|
+
|
12
|
+
def initialize(options={})
|
13
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
14
|
+
@threads = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def start
|
18
|
+
t0 = Time.now
|
19
|
+
options_per_thread = @options.dup
|
20
|
+
options_per_thread.delete(:threads)
|
21
|
+
options_per_thread[:connections] = @options[:connections] / @options[:threads]
|
22
|
+
|
23
|
+
Thread.abort_on_exception = true
|
24
|
+
|
25
|
+
@options[:threads].times do |i|
|
26
|
+
thread = spawn_thread(options_per_thread, i)
|
27
|
+
thread.start
|
28
|
+
@threads << thread
|
29
|
+
end
|
30
|
+
|
31
|
+
trap(:INT) {
|
32
|
+
puts "INT signal caught, stopping threads..."
|
33
|
+
@pipes.each { |writer| writer.write_nonblock('.') }
|
34
|
+
}
|
35
|
+
|
36
|
+
@threads.map(&:join)
|
37
|
+
|
38
|
+
@results = { time: Time.now - t0 }
|
39
|
+
@results = merge_stats(@results, @threads.map(&:results))
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def spawn_thread(options_per_thread, i)
|
45
|
+
@pipes ||= []
|
46
|
+
|
47
|
+
reader, writer = create_pipe
|
48
|
+
|
49
|
+
# selfpipe to pass signals to threads
|
50
|
+
@pipes << writer
|
51
|
+
|
52
|
+
RunnerThread.new(options_per_thread, reader, i)
|
53
|
+
end
|
54
|
+
|
55
|
+
def create_pipe
|
56
|
+
IO.method(:pipe).arity.zero? ? IO.pipe : IO.pipe("BINARY")
|
57
|
+
end
|
58
|
+
|
59
|
+
def merge_stats(dest, array)
|
60
|
+
array.each do |hash|
|
61
|
+
hash.each do |key, value|
|
62
|
+
if value.is_a? Hash
|
63
|
+
dest[key] ||= {}
|
64
|
+
merge_stats(dest[key], [value])
|
65
|
+
else
|
66
|
+
dest[key] ||= 0
|
67
|
+
dest[key] += value
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
dest
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module Mutalisk
|
2
|
+
class RunnerThread
|
3
|
+
attr_reader :results
|
4
|
+
attr_reader :thread_id
|
5
|
+
|
6
|
+
def initialize(options, pipe, id)
|
7
|
+
@options = options
|
8
|
+
@connections = []
|
9
|
+
@responses = []
|
10
|
+
|
11
|
+
@pipe = pipe
|
12
|
+
@interrupted = false
|
13
|
+
@thread_id = id
|
14
|
+
end
|
15
|
+
|
16
|
+
def start
|
17
|
+
@t0 = Time.now
|
18
|
+
|
19
|
+
@thread = Thread.new {
|
20
|
+
# slow start
|
21
|
+
until @connections.size == @options[:connections]
|
22
|
+
spawn_connection
|
23
|
+
send_requests
|
24
|
+
check_response
|
25
|
+
end
|
26
|
+
|
27
|
+
# full speed
|
28
|
+
loop do
|
29
|
+
send_requests
|
30
|
+
check_response
|
31
|
+
break if times_up? or @interrupted
|
32
|
+
end
|
33
|
+
|
34
|
+
generate_report
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def join
|
39
|
+
@thread.join
|
40
|
+
|
41
|
+
Stats.collect(@responses.map(&:to_h))
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def spawn_connection
|
47
|
+
@cid ||= 0
|
48
|
+
connection = Connection.new(self, @options[:uri], @cid)
|
49
|
+
@cid += 1
|
50
|
+
@connections << connection
|
51
|
+
end
|
52
|
+
|
53
|
+
def send_requests
|
54
|
+
@connections.each do |c|
|
55
|
+
c.send_request(@options[:headers]) if c.idle?
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def check_response
|
60
|
+
if readable = AE.readable(@connections, @pipe)
|
61
|
+
readable.each do |connection|
|
62
|
+
if connection == @pipe
|
63
|
+
@interrupted = true
|
64
|
+
else
|
65
|
+
response = connection.receive
|
66
|
+
|
67
|
+
if response.complete?
|
68
|
+
# done with a request
|
69
|
+
@responses << response
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def times_up?
|
77
|
+
Time.now - @t0 > @options[:seconds]
|
78
|
+
end
|
79
|
+
|
80
|
+
def generate_report
|
81
|
+
@results = {
|
82
|
+
bytes_read: @connections.map(&:bytes_read).inject(&:+),
|
83
|
+
requests_sent: @connections.map(&:requests_sent).inject(&:+),
|
84
|
+
responses_received: @connections.map(&:responses_received).inject(&:+),
|
85
|
+
errors: {
|
86
|
+
read: @connections.map(&:read_errors).inject(&:+),
|
87
|
+
write: @connections.map(&:write_errors).inject(&:+),
|
88
|
+
connect: @connections.map(&:connect_errors).inject(&:+),
|
89
|
+
status: @responses.map(&:code).count { |code| code < 200 || code > 399 }
|
90
|
+
}
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Mutalisk
|
2
|
+
class Stats
|
3
|
+
def self.collect(metrics)
|
4
|
+
@@metrics ||= []
|
5
|
+
@@metrics += metrics
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.reset
|
9
|
+
@@metrics = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.metrics
|
13
|
+
@@metrics
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.percentile(pc)
|
17
|
+
metrics = @@metrics.sort { |m1, m2| m1[:time_spent] <=> m2[:time_spent] }.reject { |m| m[:code] > 399 || m[:code] < 200 }
|
18
|
+
|
19
|
+
metrics[pc*metrics.size/100][:time_spent] rescue 0
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/mutalisk.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'mutalisk/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "mutalisk"
|
8
|
+
spec.version = Mutalisk::VERSION
|
9
|
+
spec.authors = ["Forrest Ye"]
|
10
|
+
spec.email = ["afu@forresty.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{A simple yet powerful HTTP API benchmarking tool.}
|
13
|
+
spec.homepage = "https://github.com/forresty/mutalisk"
|
14
|
+
|
15
|
+
# Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
|
16
|
+
# delete this section to allow pushing this gem to any host.
|
17
|
+
if spec.respond_to?(:metadata)
|
18
|
+
spec.metadata['allowed_push_host'] = "https://rubygems.org"
|
19
|
+
else
|
20
|
+
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
21
|
+
end
|
22
|
+
|
23
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
24
|
+
spec.bindir = "exe"
|
25
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
26
|
+
spec.require_paths = ["lib"]
|
27
|
+
|
28
|
+
spec.add_runtime_dependency 'ttable', '~> 0.0.10'
|
29
|
+
spec.add_runtime_dependency 'http_parser.rb', '~> 0.6.0'
|
30
|
+
spec.add_runtime_dependency 'addressable', '~> 2.3.8'
|
31
|
+
|
32
|
+
spec.add_development_dependency 'byebug', '~> 8.2.0'
|
33
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
34
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
35
|
+
spec.add_development_dependency "rspec"
|
36
|
+
end
|
metadata
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mutalisk
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Forrest Ye
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-12-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ttable
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.0.10
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.0.10
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: http_parser.rb
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.6.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.6.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: addressable
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 2.3.8
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 2.3.8
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: byebug
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 8.2.0
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 8.2.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.10'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.10'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '10.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '10.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
description:
|
112
|
+
email:
|
113
|
+
- afu@forresty.com
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- ".gitignore"
|
119
|
+
- ".rspec"
|
120
|
+
- ".travis.yml"
|
121
|
+
- Gemfile
|
122
|
+
- README.md
|
123
|
+
- Rakefile
|
124
|
+
- bin/console
|
125
|
+
- bin/mutalisk
|
126
|
+
- bin/setup
|
127
|
+
- lib/mutalisk.rb
|
128
|
+
- lib/mutalisk/ae.rb
|
129
|
+
- lib/mutalisk/cli.rb
|
130
|
+
- lib/mutalisk/connection.rb
|
131
|
+
- lib/mutalisk/errors.rb
|
132
|
+
- lib/mutalisk/request.rb
|
133
|
+
- lib/mutalisk/response.rb
|
134
|
+
- lib/mutalisk/runner.rb
|
135
|
+
- lib/mutalisk/runner_thread.rb
|
136
|
+
- lib/mutalisk/stats.rb
|
137
|
+
- lib/mutalisk/version.rb
|
138
|
+
- mutalisk.gemspec
|
139
|
+
homepage: https://github.com/forresty/mutalisk
|
140
|
+
licenses: []
|
141
|
+
metadata:
|
142
|
+
allowed_push_host: https://rubygems.org
|
143
|
+
post_install_message:
|
144
|
+
rdoc_options: []
|
145
|
+
require_paths:
|
146
|
+
- lib
|
147
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
148
|
+
requirements:
|
149
|
+
- - ">="
|
150
|
+
- !ruby/object:Gem::Version
|
151
|
+
version: '0'
|
152
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
153
|
+
requirements:
|
154
|
+
- - ">="
|
155
|
+
- !ruby/object:Gem::Version
|
156
|
+
version: '0'
|
157
|
+
requirements: []
|
158
|
+
rubyforge_project:
|
159
|
+
rubygems_version: 2.4.5
|
160
|
+
signing_key:
|
161
|
+
specification_version: 4
|
162
|
+
summary: A simple yet powerful HTTP API benchmarking tool.
|
163
|
+
test_files: []
|