sidekiq-benchmark 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.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +50 -0
- data/Rakefile +8 -0
- data/examples/web-ui.png +0 -0
- data/lib/sidekiq-benchmark.rb +12 -0
- data/lib/sidekiq-benchmark/version.rb +5 -0
- data/lib/sidekiq-benchmark/web.rb +61 -0
- data/lib/sidekiq-benchmark/worker.rb +85 -0
- data/sidekiq-benchmark.gemspec +29 -0
- data/test/lib/web_test.rb +31 -0
- data/test/lib/worker_test.rb +38 -0
- data/test/test_helper.rb +49 -0
- data/web/assets/javascripts/chartkick.js +536 -0
- data/web/views/benchmarks.slim +18 -0
- metadata +194 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Konstantin Kosmatov
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# Sidekiq::Benchmark
|
2
|
+
|
3
|
+
Adds benchmarking methods to Sidekiq workers, keeps metrics and adds tab to Web UI to let you browse them.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'sidekiq-benchmark'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
## Requirements
|
16
|
+
|
17
|
+
Redis 2.6.0 or newer required
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class SampleWorker
|
23
|
+
include Sidekiq::Worker
|
24
|
+
include Sidekiq::Benchmark::Worker
|
25
|
+
|
26
|
+
def perform(id)
|
27
|
+
benchmark do |bm|
|
28
|
+
bm.some_metric do
|
29
|
+
100500.times do
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
bm.other_metric do
|
34
|
+
something_code
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
```
|
41
|
+
## Web UI
|
42
|
+

|
43
|
+
|
44
|
+
## Contributing
|
45
|
+
|
46
|
+
1. Fork it
|
47
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
48
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
49
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
50
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/examples/web-ui.png
ADDED
Binary file
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'sidekiq/web'
|
2
|
+
require 'sidekiq-benchmark/web'
|
3
|
+
|
4
|
+
module Sidekiq
|
5
|
+
module Benchmark
|
6
|
+
autoload :Worker, 'sidekiq-benchmark/worker'
|
7
|
+
autoload :Version, 'sidekiq-benchmark/version'
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
Sidekiq::Web.register Sidekiq::Benchmark::Web
|
12
|
+
Sidekiq::Web.tabs["Benchmarks"] = "benchmarks"
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'sinatra/assetpack'
|
2
|
+
require 'chartkick'
|
3
|
+
|
4
|
+
module Sidekiq
|
5
|
+
module Benchmark
|
6
|
+
module Web
|
7
|
+
def self.registered(app)
|
8
|
+
web_dir = File.expand_path("../../../web", __FILE__)
|
9
|
+
js_dir = File.join(web_dir, "assets", "javascripts")
|
10
|
+
|
11
|
+
app.helpers Chartkick::Helper
|
12
|
+
app.register Sinatra::AssetPack
|
13
|
+
|
14
|
+
app.assets {
|
15
|
+
serve '/js', from: js_dir
|
16
|
+
|
17
|
+
js 'chartkick', ['/js/chartkick.js']
|
18
|
+
}
|
19
|
+
|
20
|
+
app.get "/benchmarks" do
|
21
|
+
@charts = {}
|
22
|
+
|
23
|
+
Sidekiq.redis do |conn|
|
24
|
+
@types = conn.smembers "benchmark:types"
|
25
|
+
@types.each do |type|
|
26
|
+
@charts[type] = { total: [], stats: [] }
|
27
|
+
|
28
|
+
total_keys = conn.hkeys("benchmark:#{type}:total") -
|
29
|
+
['start_time', 'job_time', 'finish_time']
|
30
|
+
|
31
|
+
total_time = conn.hget "benchmark:#{type}:total", :job_time
|
32
|
+
total_time = total_time.to_f
|
33
|
+
total_keys.each do |key|
|
34
|
+
value = conn.hget "benchmark:#{type}:total", key
|
35
|
+
@charts[type][:total] << [key, value.to_f.round(2)]
|
36
|
+
end
|
37
|
+
|
38
|
+
stats = conn.hgetall "benchmark:#{type}:stats"
|
39
|
+
stats.each do |key, value|
|
40
|
+
@charts[type][:stats] << [key.to_f, value.to_i]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
view_path = File.join(web_dir, "views", "benchmarks.slim")
|
46
|
+
template = File.read view_path
|
47
|
+
render :slim, template
|
48
|
+
end
|
49
|
+
|
50
|
+
app.post "/benchmarks/remove" do
|
51
|
+
Sidekiq.redis do |conn|
|
52
|
+
keys = conn.keys "benchmark:*"
|
53
|
+
conn.del keys
|
54
|
+
end
|
55
|
+
|
56
|
+
redirect "#{root_path}benchmarks"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Sidekiq
|
2
|
+
module Benchmark
|
3
|
+
module Worker
|
4
|
+
|
5
|
+
def benchmark(options = {})
|
6
|
+
bm = Benchmark.new Time.now
|
7
|
+
|
8
|
+
yield(bm)
|
9
|
+
|
10
|
+
bm.finish_time = Time.now
|
11
|
+
bm.save benchmark_redis_base_key, options
|
12
|
+
|
13
|
+
bm.set_redis_key benchmark_redis_type_key
|
14
|
+
bm
|
15
|
+
end
|
16
|
+
|
17
|
+
def benchmark_redis_type_key
|
18
|
+
@benchmark_redis_type_key ||= self.class.name.gsub('::', '_').downcase
|
19
|
+
end
|
20
|
+
|
21
|
+
def benchmark_redis_base_key
|
22
|
+
@benchmark_redis_base_key ||= "benchmark:#{benchmark_redis_type_key}"
|
23
|
+
end
|
24
|
+
|
25
|
+
class Benchmark
|
26
|
+
attr_reader :metrics, :start_time, :finish_time
|
27
|
+
|
28
|
+
def initialize(start_time)
|
29
|
+
@metrics = {}
|
30
|
+
@start_time = start_time.to_f
|
31
|
+
end
|
32
|
+
|
33
|
+
def finish_time=(value)
|
34
|
+
@finish_time = value.to_f
|
35
|
+
@metrics[:job_time] = @finish_time - start_time
|
36
|
+
end
|
37
|
+
|
38
|
+
def method_missing(name, *args)
|
39
|
+
if block_given?
|
40
|
+
start_time = Time.now
|
41
|
+
|
42
|
+
yield
|
43
|
+
|
44
|
+
finish_time = Time.now
|
45
|
+
value = finish_time.to_f - start_time.to_f
|
46
|
+
else
|
47
|
+
value = args[0].to_f
|
48
|
+
end
|
49
|
+
|
50
|
+
@metrics[name] = value
|
51
|
+
end
|
52
|
+
|
53
|
+
def set_redis_key(key)
|
54
|
+
Sidekiq.redis do |conn|
|
55
|
+
conn.sadd "benchmark:types", key
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def save(redis_base_key, options = {})
|
60
|
+
options.merge! start_time: start_time, finish_time: finish_time
|
61
|
+
options.merge! @metrics
|
62
|
+
|
63
|
+
job_time_key = @metrics[:job_time].round(1)
|
64
|
+
|
65
|
+
Sidekiq.redis do |conn|
|
66
|
+
conn.multi do
|
67
|
+
# Isn't usefull at this moment
|
68
|
+
#conn.lpush "#{redis_base_key}:jobs", Sidekiq.dump_json(options)
|
69
|
+
|
70
|
+
@metrics.each do |key, value|
|
71
|
+
conn.hincrbyfloat "#{redis_base_key}:total", key, value
|
72
|
+
conn.hincrby "#{redis_base_key}:stats", job_time_key, 1
|
73
|
+
end
|
74
|
+
|
75
|
+
conn.hsetnx "#{redis_base_key}:total", "start_time", start_time
|
76
|
+
conn.hincrbyfloat "#{redis_base_key}:total", "job_time", @metrics[:job_time]
|
77
|
+
conn.hset "#{redis_base_key}:total", "finish_time", finish_time
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'sidekiq-benchmark/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "sidekiq-benchmark"
|
8
|
+
gem.version = Sidekiq::Benchmark::VERSION
|
9
|
+
gem.authors = ["Konstantin Kosmatov"]
|
10
|
+
gem.email = ["key@kosmatov.ru"]
|
11
|
+
gem.description = %q{Benchmarks for Sidekiq}
|
12
|
+
gem.summary = %q{Adds benchmarking methods to Sidekiq workers, keeps metrics and adds tab to Web UI to let you browse them.}
|
13
|
+
gem.homepage = "https://github.com/kosmatov/sidekiq-benchmark/"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_dependency "chartkick"
|
21
|
+
gem.add_dependency "sinatra-assetpack"
|
22
|
+
|
23
|
+
gem.add_development_dependency "sidekiq"
|
24
|
+
gem.add_development_dependency "slim"
|
25
|
+
gem.add_development_dependency "sinatra"
|
26
|
+
gem.add_development_dependency "rake"
|
27
|
+
gem.add_development_dependency "rack-test"
|
28
|
+
gem.add_development_dependency "minitest", "~> 5"
|
29
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module Benchmark
|
5
|
+
module Test
|
6
|
+
describe "Web extention" do
|
7
|
+
include Rack::Test::Methods
|
8
|
+
|
9
|
+
def app
|
10
|
+
@app ||= Sidekiq::Web
|
11
|
+
end
|
12
|
+
|
13
|
+
before do
|
14
|
+
Test.flush_db
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should display index without stats" do
|
18
|
+
get '/benchmarks'
|
19
|
+
last_response.status.must_equal 200
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should display index with stats" do
|
23
|
+
WorkerMock.new
|
24
|
+
|
25
|
+
get '/benchmarks'
|
26
|
+
last_response.status.must_equal 200, last_response.body
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module Benchmark
|
5
|
+
module Test
|
6
|
+
|
7
|
+
class WorkerTest < Minitest::Spec
|
8
|
+
include Sidekiq::Benchmark::Worker
|
9
|
+
|
10
|
+
before do
|
11
|
+
Test.flush_db
|
12
|
+
@worker = WorkerMock.new
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should collect metrics" do
|
16
|
+
metrics = @worker.bm_obj.metrics
|
17
|
+
|
18
|
+
@worker.metric_names.each do |metric_name|
|
19
|
+
assert metrics[metric_name]
|
20
|
+
end
|
21
|
+
|
22
|
+
assert @worker.bm_obj.start_time
|
23
|
+
assert @worker.bm_obj.finish_time
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should save metrics to redis" do
|
27
|
+
Sidekiq.redis do |conn|
|
28
|
+
total_time = conn.hget("#{@worker.benchmark_redis_base_key}:total", :job_time)
|
29
|
+
assert total_time, "Total time: #{total_time.inspect}"
|
30
|
+
|
31
|
+
metrics = conn.hkeys("#{@worker.benchmark_redis_base_key}:stats")
|
32
|
+
assert metrics.any?, "Metrics: #{metrics.inspect}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'minitest/pride'
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'rack/test'
|
5
|
+
|
6
|
+
require 'sidekiq'
|
7
|
+
require 'sidekiq/util'
|
8
|
+
require 'sidekiq-benchmark'
|
9
|
+
|
10
|
+
REDIS = Sidekiq::RedisConnection.create url: "redis://localhost/15", namespace: "testy"
|
11
|
+
|
12
|
+
Bundler.require
|
13
|
+
|
14
|
+
module Sidekiq
|
15
|
+
module Benchmark
|
16
|
+
module Test
|
17
|
+
|
18
|
+
class WorkerMock
|
19
|
+
include Sidekiq::Worker
|
20
|
+
include Sidekiq::Benchmark::Worker
|
21
|
+
|
22
|
+
attr_reader :bm_obj, :metric_names
|
23
|
+
|
24
|
+
def initialize
|
25
|
+
@bm_obj = benchmark do |bm|
|
26
|
+
bm.test_metric do
|
27
|
+
2.times do |i|
|
28
|
+
bm.send("nested_test_metric_#{i}") do
|
29
|
+
100500.times do |i|
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
@metric_names = [:test_metric, :nested_test_metric_1, :job_time]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.flush_db
|
41
|
+
Sidekiq.redis = REDIS
|
42
|
+
Sidekiq.redis do |conn|
|
43
|
+
conn.flushdb
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,536 @@
|
|
1
|
+
/*jslint browser: true, indent: 2, plusplus: true */
|
2
|
+
/*global google, $*/
|
3
|
+
|
4
|
+
(function() {
|
5
|
+
'use strict';
|
6
|
+
|
7
|
+
// http://stackoverflow.com/questions/728360/most-elegant-way-to-clone-a-javascript-object
|
8
|
+
function clone(obj) {
|
9
|
+
var copy, i, attr, len;
|
10
|
+
|
11
|
+
// Handle the 3 simple types, and null or undefined
|
12
|
+
if (null === obj || "object" !== typeof obj) {
|
13
|
+
return obj;
|
14
|
+
}
|
15
|
+
|
16
|
+
// Handle Date
|
17
|
+
if (obj instanceof Date) {
|
18
|
+
copy = new Date();
|
19
|
+
copy.setTime(obj.getTime());
|
20
|
+
return copy;
|
21
|
+
}
|
22
|
+
|
23
|
+
// Handle Array
|
24
|
+
if (obj instanceof Array) {
|
25
|
+
copy = [];
|
26
|
+
for (i = 0, len = obj.length; i < len; i++) {
|
27
|
+
copy[i] = clone(obj[i]);
|
28
|
+
}
|
29
|
+
return copy;
|
30
|
+
}
|
31
|
+
|
32
|
+
// Handle Object
|
33
|
+
if (obj instanceof Object) {
|
34
|
+
copy = {};
|
35
|
+
for (attr in obj) {
|
36
|
+
if (obj.hasOwnProperty(attr)) {
|
37
|
+
copy[attr] = clone(obj[attr]);
|
38
|
+
}
|
39
|
+
}
|
40
|
+
return copy;
|
41
|
+
}
|
42
|
+
|
43
|
+
throw new Error("Unable to copy obj! Its type isn't supported.");
|
44
|
+
}
|
45
|
+
|
46
|
+
// https://github.com/Do/iso8601.js
|
47
|
+
var ISO8601_PATTERN = /(\d\d\d\d)(\-)?(\d\d)(\-)?(\d\d)(T)?(\d\d)(:)?(\d\d)?(:)?(\d\d)?([\.,]\d+)?($|Z|([\+\-])(\d\d)(:)?(\d\d)?)/i;
|
48
|
+
var DECIMAL_SEPARATOR = String(1.5).charAt(1);
|
49
|
+
|
50
|
+
function parseISO8601(input) {
|
51
|
+
var day, hour, matches, milliseconds, minutes, month, offset, result, seconds, type, year;
|
52
|
+
type = Object.prototype.toString.call(input);
|
53
|
+
if (type === '[object Date]') {
|
54
|
+
return input;
|
55
|
+
}
|
56
|
+
if (type !== '[object String]') {
|
57
|
+
return;
|
58
|
+
}
|
59
|
+
if (matches = input.match(ISO8601_PATTERN)) {
|
60
|
+
year = parseInt(matches[1], 10);
|
61
|
+
month = parseInt(matches[3], 10) - 1;
|
62
|
+
day = parseInt(matches[5], 10);
|
63
|
+
hour = parseInt(matches[7], 10);
|
64
|
+
minutes = matches[9] ? parseInt(matches[9], 10) : 0;
|
65
|
+
seconds = matches[11] ? parseInt(matches[11], 10) : 0;
|
66
|
+
milliseconds = matches[12] ? parseFloat(DECIMAL_SEPARATOR + matches[12].slice(1)) * 1000 : 0;
|
67
|
+
result = Date.UTC(year, month, day, hour, minutes, seconds, milliseconds);
|
68
|
+
if (matches[13] && matches[14]) {
|
69
|
+
offset = matches[15] * 60;
|
70
|
+
if (matches[17]) {
|
71
|
+
offset += parseInt(matches[17], 10);
|
72
|
+
}
|
73
|
+
offset *= matches[14] === '-' ? -1 : 1;
|
74
|
+
result -= offset * 60 * 1000;
|
75
|
+
}
|
76
|
+
return new Date(result);
|
77
|
+
}
|
78
|
+
}
|
79
|
+
// end iso8601.js
|
80
|
+
|
81
|
+
function negativeValues(series) {
|
82
|
+
var i, j, data;
|
83
|
+
for (i = 0; i < series.length; i++) {
|
84
|
+
data = series[i].data;
|
85
|
+
for (j = 0; j < data.length; j++) {
|
86
|
+
if (data[j][1] < 0) {
|
87
|
+
return true;
|
88
|
+
}
|
89
|
+
}
|
90
|
+
}
|
91
|
+
return false;
|
92
|
+
}
|
93
|
+
|
94
|
+
function jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax) {
|
95
|
+
return function(series, opts) {
|
96
|
+
var options = clone(defaultOptions);
|
97
|
+
|
98
|
+
// hide legend
|
99
|
+
if (series.length === 1) {
|
100
|
+
hideLegend(options);
|
101
|
+
}
|
102
|
+
|
103
|
+
// min
|
104
|
+
if ("min" in opts) {
|
105
|
+
setMin(options, opts.min);
|
106
|
+
}
|
107
|
+
else if (!negativeValues(series)) {
|
108
|
+
setMin(options, 0);
|
109
|
+
}
|
110
|
+
|
111
|
+
// max
|
112
|
+
if ("max" in opts) {
|
113
|
+
setMax(options, opts.max);
|
114
|
+
}
|
115
|
+
|
116
|
+
return options;
|
117
|
+
};
|
118
|
+
}
|
119
|
+
|
120
|
+
// only functions that need defined specific to charting library
|
121
|
+
var renderLineChart, renderPieChart, renderColumnChart;
|
122
|
+
|
123
|
+
if ("Highcharts" in window) {
|
124
|
+
|
125
|
+
var defaultOptions = {
|
126
|
+
xAxis: {
|
127
|
+
labels: {
|
128
|
+
style: {
|
129
|
+
fontSize: "12px"
|
130
|
+
}
|
131
|
+
}
|
132
|
+
},
|
133
|
+
yAxis: {
|
134
|
+
title: {
|
135
|
+
text: null
|
136
|
+
},
|
137
|
+
labels: {
|
138
|
+
style: {
|
139
|
+
fontSize: "12px"
|
140
|
+
}
|
141
|
+
}
|
142
|
+
},
|
143
|
+
title: {
|
144
|
+
text: null
|
145
|
+
},
|
146
|
+
credits: {
|
147
|
+
enabled: false
|
148
|
+
},
|
149
|
+
legend: {
|
150
|
+
borderWidth: 0
|
151
|
+
},
|
152
|
+
tooltip: {
|
153
|
+
style: {
|
154
|
+
fontSize: "12px"
|
155
|
+
}
|
156
|
+
}
|
157
|
+
};
|
158
|
+
|
159
|
+
var hideLegend = function(options) {
|
160
|
+
options.legend.enabled = false;
|
161
|
+
};
|
162
|
+
|
163
|
+
var setMin = function(options, min) {
|
164
|
+
options.yAxis.min = min;
|
165
|
+
};
|
166
|
+
|
167
|
+
var setMax = function(options, max) {
|
168
|
+
options.yAxis.max = max;
|
169
|
+
};
|
170
|
+
|
171
|
+
var jsOptions = jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax);
|
172
|
+
|
173
|
+
renderLineChart = function(element, series, opts) {
|
174
|
+
var options = jsOptions(series, opts), data, i, j;
|
175
|
+
options.xAxis.type = "datetime";
|
176
|
+
options.chart = {type: "spline"};
|
177
|
+
|
178
|
+
for (i = 0; i < series.length; i++) {
|
179
|
+
data = series[i].data;
|
180
|
+
for (j = 0; j < data.length; j++) {
|
181
|
+
data[j][0] = data[j][0].getTime();
|
182
|
+
}
|
183
|
+
series[i].marker = {symbol: "circle"};
|
184
|
+
}
|
185
|
+
options.series = series;
|
186
|
+
$(element).highcharts(options);
|
187
|
+
};
|
188
|
+
|
189
|
+
renderPieChart = function(element, series, opts) {
|
190
|
+
var options = clone(defaultOptions);
|
191
|
+
options.series = [{
|
192
|
+
type: "pie",
|
193
|
+
name: "Value",
|
194
|
+
data: series
|
195
|
+
}];
|
196
|
+
$(element).highcharts(options);
|
197
|
+
};
|
198
|
+
|
199
|
+
renderColumnChart = function(element, series, opts) {
|
200
|
+
var options = jsOptions(series, opts), i, j, s, d, rows = [];
|
201
|
+
options.chart = {type: "column"};
|
202
|
+
|
203
|
+
for (i = 0; i < series.length; i++) {
|
204
|
+
s = series[i];
|
205
|
+
|
206
|
+
for (j = 0; j < s.data.length; j++) {
|
207
|
+
d = s.data[j];
|
208
|
+
if (!rows[d[0]]) {
|
209
|
+
rows[d[0]] = new Array(series.length);
|
210
|
+
}
|
211
|
+
rows[d[0]][i] = d[1];
|
212
|
+
}
|
213
|
+
}
|
214
|
+
|
215
|
+
var categories = [];
|
216
|
+
for (i in rows) {
|
217
|
+
if (rows.hasOwnProperty(i)) {
|
218
|
+
categories.push(i);
|
219
|
+
}
|
220
|
+
}
|
221
|
+
options.xAxis.categories = categories;
|
222
|
+
|
223
|
+
var newSeries = [];
|
224
|
+
for (i = 0; i < series.length; i++) {
|
225
|
+
d = [];
|
226
|
+
for (j = 0; j < categories.length; j++) {
|
227
|
+
d.push(rows[categories[j]][i]);
|
228
|
+
}
|
229
|
+
|
230
|
+
newSeries.push({
|
231
|
+
name: series[i].name,
|
232
|
+
data: d
|
233
|
+
});
|
234
|
+
}
|
235
|
+
options.series = newSeries;
|
236
|
+
|
237
|
+
$(element).highcharts(options);
|
238
|
+
};
|
239
|
+
} else if ("google" in window) { // Google charts
|
240
|
+
// load from google
|
241
|
+
var loaded = false;
|
242
|
+
google.setOnLoadCallback(function() {
|
243
|
+
loaded = true;
|
244
|
+
});
|
245
|
+
google.load("visualization", "1.0", {"packages": ["corechart"]});
|
246
|
+
|
247
|
+
var waitForLoaded = function(callback) {
|
248
|
+
google.setOnLoadCallback(callback); // always do this to prevent race conditions (watch out for other issues due to this)
|
249
|
+
if (loaded) {
|
250
|
+
callback();
|
251
|
+
}
|
252
|
+
};
|
253
|
+
|
254
|
+
// Set chart options
|
255
|
+
var defaultOptions = {
|
256
|
+
fontName: "'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif",
|
257
|
+
pointSize: 6,
|
258
|
+
legend: {
|
259
|
+
textStyle: {
|
260
|
+
fontSize: 12,
|
261
|
+
color: "#444"
|
262
|
+
},
|
263
|
+
alignment: "center",
|
264
|
+
position: "right"
|
265
|
+
},
|
266
|
+
curveType: "function",
|
267
|
+
hAxis: {
|
268
|
+
textStyle: {
|
269
|
+
color: "#666",
|
270
|
+
fontSize: 12
|
271
|
+
},
|
272
|
+
gridlines: {
|
273
|
+
color: "transparent"
|
274
|
+
},
|
275
|
+
baselineColor: "#ccc"
|
276
|
+
},
|
277
|
+
vAxis: {
|
278
|
+
textStyle: {
|
279
|
+
color: "#666",
|
280
|
+
fontSize: 12
|
281
|
+
},
|
282
|
+
baselineColor: "#ccc",
|
283
|
+
viewWindow: {}
|
284
|
+
},
|
285
|
+
tooltip: {
|
286
|
+
textStyle: {
|
287
|
+
color: "#666",
|
288
|
+
fontSize: 12
|
289
|
+
}
|
290
|
+
}
|
291
|
+
};
|
292
|
+
|
293
|
+
var hideLegend = function(options) {
|
294
|
+
options.legend.position = "none";
|
295
|
+
};
|
296
|
+
|
297
|
+
var setMin = function(options, min) {
|
298
|
+
options.vAxis.viewWindow.min = min;
|
299
|
+
};
|
300
|
+
|
301
|
+
var setMax = function(options, max) {
|
302
|
+
options.vAxis.viewWindow.max = max;
|
303
|
+
};
|
304
|
+
|
305
|
+
var jsOptions = jsOptionsFunc(defaultOptions, hideLegend, setMin, setMax);
|
306
|
+
|
307
|
+
// cant use object as key
|
308
|
+
var createDataTable = function(series, columnType) {
|
309
|
+
var data = new google.visualization.DataTable();
|
310
|
+
data.addColumn(columnType, "");
|
311
|
+
|
312
|
+
var i, j, s, d, key, rows = [];
|
313
|
+
for (i = 0; i < series.length; i++) {
|
314
|
+
s = series[i];
|
315
|
+
data.addColumn("number", s.name);
|
316
|
+
|
317
|
+
for (j = 0; j < s.data.length; j++) {
|
318
|
+
d = s.data[j];
|
319
|
+
key = (columnType === "datetime") ? d[0].getTime() : d[0];
|
320
|
+
if (!rows[key]) {
|
321
|
+
rows[key] = new Array(series.length);
|
322
|
+
}
|
323
|
+
rows[key][i] = toFloat(d[1]);
|
324
|
+
}
|
325
|
+
}
|
326
|
+
|
327
|
+
var rows2 = [];
|
328
|
+
for (i in rows) {
|
329
|
+
if (rows.hasOwnProperty(i)) {
|
330
|
+
rows2.push([(columnType === "datetime") ? new Date(toFloat(i)) : i].concat(rows[i]));
|
331
|
+
}
|
332
|
+
}
|
333
|
+
data.addRows(rows2);
|
334
|
+
|
335
|
+
return data;
|
336
|
+
};
|
337
|
+
|
338
|
+
renderLineChart = function(element, series, opts) {
|
339
|
+
waitForLoaded(function() {
|
340
|
+
var options = jsOptions(series, opts);
|
341
|
+
var data = createDataTable(series, "datetime");
|
342
|
+
var chart = new google.visualization.LineChart(element);
|
343
|
+
chart.draw(data, options);
|
344
|
+
});
|
345
|
+
};
|
346
|
+
|
347
|
+
renderPieChart = function(element, series, opts) {
|
348
|
+
waitForLoaded(function() {
|
349
|
+
var options = clone(defaultOptions);
|
350
|
+
options.chartArea = {
|
351
|
+
top: "10%",
|
352
|
+
height: "80%"
|
353
|
+
};
|
354
|
+
|
355
|
+
var data = new google.visualization.DataTable();
|
356
|
+
data.addColumn("string", "");
|
357
|
+
data.addColumn("number", "Value");
|
358
|
+
data.addRows(series);
|
359
|
+
|
360
|
+
var chart = new google.visualization.PieChart(element);
|
361
|
+
chart.draw(data, options);
|
362
|
+
});
|
363
|
+
};
|
364
|
+
|
365
|
+
renderColumnChart = function(element, series, opts) {
|
366
|
+
waitForLoaded(function() {
|
367
|
+
var options = jsOptions(series, opts);
|
368
|
+
var data = createDataTable(series, "string");
|
369
|
+
var chart = new google.visualization.ColumnChart(element);
|
370
|
+
chart.draw(data, options);
|
371
|
+
});
|
372
|
+
};
|
373
|
+
} else { // no chart library installed
|
374
|
+
renderLineChart = renderPieChart = renderColumnChart = function() {
|
375
|
+
throw new Error("Please install Google Charts or Highcharts");
|
376
|
+
};
|
377
|
+
}
|
378
|
+
|
379
|
+
function setText(element, text) {
|
380
|
+
if (document.body.innerText) {
|
381
|
+
element.innerText = text;
|
382
|
+
} else {
|
383
|
+
element.textContent = text;
|
384
|
+
}
|
385
|
+
}
|
386
|
+
|
387
|
+
function chartError(element, message) {
|
388
|
+
setText(element, "Error Loading Chart: " + message);
|
389
|
+
element.style.color = "#ff0000";
|
390
|
+
}
|
391
|
+
|
392
|
+
function getJSON(element, url, success) {
|
393
|
+
$.ajax({
|
394
|
+
dataType: "json",
|
395
|
+
url: url,
|
396
|
+
success: success,
|
397
|
+
error: function(jqXHR, textStatus, errorThrown) {
|
398
|
+
var message = (typeof errorThrown === "string") ? errorThrown : errorThrown.message;
|
399
|
+
chartError(element, message);
|
400
|
+
}
|
401
|
+
});
|
402
|
+
}
|
403
|
+
|
404
|
+
function errorCatcher(element, data, opts, callback) {
|
405
|
+
try {
|
406
|
+
callback(element, data, opts);
|
407
|
+
} catch (err) {
|
408
|
+
chartError(element, err.message);
|
409
|
+
throw err;
|
410
|
+
}
|
411
|
+
}
|
412
|
+
|
413
|
+
function fetchDataSource(element, dataSource, opts, callback) {
|
414
|
+
if (typeof dataSource === "string") {
|
415
|
+
getJSON(element, dataSource, function(data, textStatus, jqXHR) {
|
416
|
+
errorCatcher(element, data, opts, callback);
|
417
|
+
});
|
418
|
+
} else {
|
419
|
+
errorCatcher(element, dataSource, opts, callback);
|
420
|
+
}
|
421
|
+
}
|
422
|
+
|
423
|
+
// helpers
|
424
|
+
|
425
|
+
function isArray(variable) {
|
426
|
+
return Object.prototype.toString.call(variable) === "[object Array]";
|
427
|
+
}
|
428
|
+
|
429
|
+
// type conversions
|
430
|
+
|
431
|
+
function toStr(n) {
|
432
|
+
return "" + n;
|
433
|
+
}
|
434
|
+
|
435
|
+
function toFloat(n) {
|
436
|
+
return parseFloat(n);
|
437
|
+
}
|
438
|
+
|
439
|
+
function toDate(n) {
|
440
|
+
if (typeof n !== "object") {
|
441
|
+
if (typeof n === "number") {
|
442
|
+
n = new Date(n * 1000); // ms
|
443
|
+
} else { // str
|
444
|
+
// try our best to get the str into iso8601
|
445
|
+
// TODO be smarter about this
|
446
|
+
var str = n.replace(/ /, "T").replace(" ", "").replace("UTC", "Z");
|
447
|
+
n = parseISO8601(str) || new Date(n);
|
448
|
+
}
|
449
|
+
}
|
450
|
+
return n;
|
451
|
+
}
|
452
|
+
|
453
|
+
function toArr(n) {
|
454
|
+
if (!isArray(n)) {
|
455
|
+
var arr = [], i;
|
456
|
+
for (i in n) {
|
457
|
+
if (n.hasOwnProperty(i)) {
|
458
|
+
arr.push([i, n[i]]);
|
459
|
+
}
|
460
|
+
}
|
461
|
+
n = arr;
|
462
|
+
}
|
463
|
+
return n;
|
464
|
+
}
|
465
|
+
|
466
|
+
// process data
|
467
|
+
|
468
|
+
function sortByTime(a, b) {
|
469
|
+
return a[0].getTime() - b[0].getTime();
|
470
|
+
}
|
471
|
+
|
472
|
+
function processSeries(series, time) {
|
473
|
+
var i, j, data, r, key;
|
474
|
+
|
475
|
+
// see if one series or multiple
|
476
|
+
if (!isArray(series) || typeof series[0] !== "object" || isArray(series[0])) {
|
477
|
+
series = [{name: "Value", data: series}];
|
478
|
+
}
|
479
|
+
|
480
|
+
// right format
|
481
|
+
for (i = 0; i < series.length; i++) {
|
482
|
+
data = toArr(series[i].data);
|
483
|
+
r = [];
|
484
|
+
for (j = 0; j < data.length; j++) {
|
485
|
+
key = data[j][0];
|
486
|
+
key = time ? toDate(key) : toStr(key);
|
487
|
+
r.push([key, toFloat(data[j][1])]);
|
488
|
+
}
|
489
|
+
if (time) {
|
490
|
+
r.sort(sortByTime);
|
491
|
+
}
|
492
|
+
series[i].data = r;
|
493
|
+
}
|
494
|
+
|
495
|
+
return series;
|
496
|
+
}
|
497
|
+
|
498
|
+
function processLineData(element, data, opts) {
|
499
|
+
renderLineChart(element, processSeries(data, true), opts);
|
500
|
+
}
|
501
|
+
|
502
|
+
function processColumnData(element, data, opts) {
|
503
|
+
renderColumnChart(element, processSeries(data, false), opts);
|
504
|
+
}
|
505
|
+
|
506
|
+
function processPieData(element, data, opts) {
|
507
|
+
var perfectData = toArr(data), i;
|
508
|
+
for (i = 0; i < perfectData.length; i++) {
|
509
|
+
perfectData[i] = [toStr(perfectData[i][0]), toFloat(perfectData[i][1])];
|
510
|
+
}
|
511
|
+
renderPieChart(element, perfectData, opts);
|
512
|
+
}
|
513
|
+
|
514
|
+
function setElement(element, data, opts, callback) {
|
515
|
+
if (typeof element === "string") {
|
516
|
+
element = document.getElementById(element);
|
517
|
+
}
|
518
|
+
fetchDataSource(element, data, opts || {}, callback);
|
519
|
+
}
|
520
|
+
|
521
|
+
// define classes
|
522
|
+
|
523
|
+
var Chartkick = {
|
524
|
+
LineChart: function(element, dataSource, opts) {
|
525
|
+
setElement(element, dataSource, opts, processLineData);
|
526
|
+
},
|
527
|
+
ColumnChart: function(element, dataSource, opts) {
|
528
|
+
setElement(element, dataSource, opts, processColumnData);
|
529
|
+
},
|
530
|
+
PieChart: function(element, dataSource, opts) {
|
531
|
+
setElement(element, dataSource, opts, processPieData);
|
532
|
+
}
|
533
|
+
};
|
534
|
+
|
535
|
+
window.Chartkick = Chartkick;
|
536
|
+
})();
|
@@ -0,0 +1,18 @@
|
|
1
|
+
script src="//www.google.com/jsapi"
|
2
|
+
==js :chartkick
|
3
|
+
|
4
|
+
header.row
|
5
|
+
.span5
|
6
|
+
h3 Benchmarks
|
7
|
+
|
8
|
+
section
|
9
|
+
- @types.each do |type|
|
10
|
+
section
|
11
|
+
h4= type
|
12
|
+
figure.row
|
13
|
+
.span5
|
14
|
+
h5 Count jobs by execution time
|
15
|
+
== column_chart @charts[type][:stats]
|
16
|
+
.span4
|
17
|
+
h5 Average execution time
|
18
|
+
== pie_chart @charts[type][:total]
|
metadata
ADDED
@@ -0,0 +1,194 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sidekiq-benchmark
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Konstantin Kosmatov
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-06-11 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: chartkick
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: sinatra-assetpack
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: sidekiq
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: slim
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: sinatra
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: rake
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: rack-test
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: minitest
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ~>
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '5'
|
134
|
+
type: :development
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ~>
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '5'
|
142
|
+
description: Benchmarks for Sidekiq
|
143
|
+
email:
|
144
|
+
- key@kosmatov.ru
|
145
|
+
executables: []
|
146
|
+
extensions: []
|
147
|
+
extra_rdoc_files: []
|
148
|
+
files:
|
149
|
+
- .gitignore
|
150
|
+
- Gemfile
|
151
|
+
- LICENSE.txt
|
152
|
+
- README.md
|
153
|
+
- Rakefile
|
154
|
+
- examples/web-ui.png
|
155
|
+
- lib/sidekiq-benchmark.rb
|
156
|
+
- lib/sidekiq-benchmark/version.rb
|
157
|
+
- lib/sidekiq-benchmark/web.rb
|
158
|
+
- lib/sidekiq-benchmark/worker.rb
|
159
|
+
- sidekiq-benchmark.gemspec
|
160
|
+
- test/lib/web_test.rb
|
161
|
+
- test/lib/worker_test.rb
|
162
|
+
- test/test_helper.rb
|
163
|
+
- web/assets/javascripts/chartkick.js
|
164
|
+
- web/views/benchmarks.slim
|
165
|
+
homepage: https://github.com/kosmatov/sidekiq-benchmark/
|
166
|
+
licenses: []
|
167
|
+
post_install_message:
|
168
|
+
rdoc_options: []
|
169
|
+
require_paths:
|
170
|
+
- lib
|
171
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
172
|
+
none: false
|
173
|
+
requirements:
|
174
|
+
- - ! '>='
|
175
|
+
- !ruby/object:Gem::Version
|
176
|
+
version: '0'
|
177
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
178
|
+
none: false
|
179
|
+
requirements:
|
180
|
+
- - ! '>='
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0'
|
183
|
+
requirements: []
|
184
|
+
rubyforge_project:
|
185
|
+
rubygems_version: 1.8.25
|
186
|
+
signing_key:
|
187
|
+
specification_version: 3
|
188
|
+
summary: Adds benchmarking methods to Sidekiq workers, keeps metrics and adds tab
|
189
|
+
to Web UI to let you browse them.
|
190
|
+
test_files:
|
191
|
+
- test/lib/web_test.rb
|
192
|
+
- test/lib/worker_test.rb
|
193
|
+
- test/test_helper.rb
|
194
|
+
has_rdoc:
|