test-queue 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,26 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ test-queue (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ diff-lcs (1.2.4)
10
+ minitest (4.7.3)
11
+ rspec (2.13.0)
12
+ rspec-core (~> 2.13.0)
13
+ rspec-expectations (~> 2.13.0)
14
+ rspec-mocks (~> 2.13.0)
15
+ rspec-core (2.13.1)
16
+ rspec-expectations (2.13.0)
17
+ diff-lcs (>= 1.1.3, < 2.0)
18
+ rspec-mocks (2.13.1)
19
+
20
+ PLATFORMS
21
+ ruby
22
+
23
+ DEPENDENCIES
24
+ minitest
25
+ rspec
26
+ test-queue!
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ ## test-queue
2
+
3
+ Yet another parallel test runner, built using a centralized queue to ensure
4
+ optimal distribution of tests between workers.
5
+
6
+ Specifially optimized for CI environments: build statistics from each run
7
+ are stored locally and used to sort the queue at the beginning of the
8
+ next run.
9
+
10
+ ### usage
11
+
12
+ ```
13
+ $ minitest-queue $(find test/ -name \*_test.rb)
14
+ $ rspec-queue $(find spec/ -name \*_spec.rb)
15
+ ```
16
+
17
+ ### design
18
+
19
+ test-queue uses a simple master + pre-fork worker model. The master
20
+ exposes a unix domain socket server which workers use to grab tests off
21
+ the queue.
22
+
23
+ ```
24
+ ─┬─ 21232 minitest-queue master
25
+ ├─── 21571 minitest-queue worker [3] - AuthenticationTest
26
+ ├─── 21568 minitest-queue worker [2] - ApiTest
27
+ ├─── 21565 minitest-queue worker [1] - UsersControllerTest
28
+ └─── 21562 minitest-queue worker [0] - UserTest
29
+ ```
30
+
31
+ ### customization
32
+
33
+ Since test-queue uses `fork(2)` to spawn off workers, you must ensure each worker
34
+ runs in an isolated environment. Use the `after_fork` hook with a custom
35
+ runner to reset any global state:
36
+
37
+ ``` ruby
38
+ class CustomMiniTestRunner < TestQueue::Runner::MiniTest
39
+ def after_fork(num)
40
+ super
41
+
42
+ # use separate mysql database (we assume it exists and has the right schema already)
43
+ ActiveRecord::Base.configurations['test']['database'] << num.to_s
44
+ ActiveRecord::Base.establish_connection(:test)
45
+
46
+ # use separate redis database
47
+ $redis.client.db = num
48
+ $redis.client.reconnect
49
+ end
50
+ end
51
+
52
+ CustomMiniTestRunner.new.execute
53
+ ```
54
+
55
+ ### see also
56
+
57
+ * https://github.com/Shopify/rails_parallel
58
+ * https://github.com/grosser/parallel_tests
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ require 'test_queue'
3
+ require 'test_queue/runner/minitest'
4
+ ARGV.each{ |f| require(f) }
5
+ TestQueue::Runner::MiniTest.new.execute
data/bin/rspec-queue ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'test_queue'
3
+ require 'test_queue/runner/rspec'
4
+ TestQueue::Runner::RSpec.new.execute
data/lib/test-queue.rb ADDED
@@ -0,0 +1 @@
1
+ require 'test_queue'
data/lib/test_queue.rb ADDED
@@ -0,0 +1,8 @@
1
+ if !IO.respond_to?(:binread)
2
+ class << IO
3
+ alias :binread :read
4
+ end
5
+ end
6
+
7
+ require 'test_queue/iterator'
8
+ require 'test_queue/runner'
@@ -0,0 +1,44 @@
1
+ module TestQueue
2
+ class Iterator
3
+ attr_reader :stats
4
+
5
+ def initialize(sock)
6
+ @sock = sock
7
+ @done = false
8
+ @stats = {}
9
+ end
10
+
11
+ def each
12
+ fail 'already used this iterator' if @done
13
+
14
+ while true
15
+ client = UNIXSocket.new(@sock)
16
+ r, w, e = IO.select([client], nil, [client], nil)
17
+ break if !e.empty?
18
+
19
+ if data = client.read(16384)
20
+ client.close
21
+ item = Marshal.load(data)
22
+
23
+ start = Time.now
24
+ yield item
25
+ @stats[item] = Time.now - start
26
+ else
27
+ break
28
+ end
29
+ end
30
+ rescue Errno::ENOENT, Errno::ECONNRESET, Errno::ECONNREFUSED
31
+ ensure
32
+ @done = true
33
+ File.open("/tmp/test_queue_worker_#{$$}_stats", "wb") do |f|
34
+ f.write Marshal.dump(@stats)
35
+ end
36
+ end
37
+
38
+ include Enumerable
39
+
40
+ def empty?
41
+ false
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,201 @@
1
+ require 'socket'
2
+ require 'fileutils'
3
+
4
+ module TestQueue
5
+ class Worker
6
+ attr_accessor :pid, :status, :output, :stats, :num
7
+ attr_accessor :start_time, :end_time
8
+
9
+ def initialize(pid, num)
10
+ @pid = pid
11
+ @num = num
12
+ @start_time = Time.now
13
+ @output = ''
14
+ end
15
+
16
+ def lines
17
+ @output.split("\n")
18
+ end
19
+ end
20
+
21
+ class Runner
22
+ attr_accessor :concurrency
23
+
24
+ def initialize(queue, concurrency=nil)
25
+ raise ArgumentError, 'array required' unless Array === queue
26
+
27
+ @queue = queue
28
+
29
+ @workers = {}
30
+ @completed = []
31
+
32
+ @concurrency =
33
+ concurrency ||
34
+ (ENV['TEST_QUEUE_WORKERS'] && ENV['TEST_QUEUE_WORKERS'].to_i) ||
35
+ if File.exists?('/proc/cpuinfo')
36
+ File.read('/proc/cpuinfo').split("\n").grep(/processor/).size
37
+ elsif RUBY_PLATFORM =~ /darwin/
38
+ `/usr/sbin/sysctl -n hw.activecpu`.to_i
39
+ else
40
+ 2
41
+ end
42
+ end
43
+
44
+ def stats
45
+ @stats ||=
46
+ if File.exists?('.test_queue_stats')
47
+ Marshal.load(IO.binread('.test_queue_stats'))
48
+ else
49
+ {}
50
+ end
51
+ end
52
+
53
+ def execute
54
+ @concurrency > 0 ?
55
+ execute_parallel :
56
+ execute_sequential
57
+ ensure
58
+ puts
59
+ puts "==> Summary"
60
+ puts
61
+
62
+ @failures = ''
63
+ @completed.each do |worker|
64
+ summary, failures = summarize_worker(worker)
65
+ @failures << failures if failures
66
+
67
+ puts " [%d] %55s in %.4fs (pid %d exit %d)" % [
68
+ worker.num,
69
+ summary,
70
+ worker.end_time - worker.start_time,
71
+ worker.pid,
72
+ worker.status.exitstatus
73
+ ]
74
+ end
75
+
76
+ unless @failures.empty?
77
+ puts
78
+ puts "==> Failures"
79
+ puts
80
+ puts @failures
81
+ end
82
+
83
+ puts
84
+
85
+ File.open('.test_queue_stats', 'wb') do |f|
86
+ f.write Marshal.dump(@stats)
87
+ end
88
+
89
+ exit! @completed.inject(0){ |s, worker| s + worker.status.exitstatus }
90
+ end
91
+
92
+ def execute_sequential
93
+ exit! run_worker(@queue)
94
+ end
95
+
96
+ def execute_parallel
97
+ start_master
98
+ spawn_workers
99
+ distribute_queue
100
+ ensure
101
+ stop_master
102
+
103
+ @workers.each do |pid, worker|
104
+ Process.kill 'KILL', pid
105
+ end
106
+
107
+ until @workers.empty?
108
+ cleanup_worker
109
+ end
110
+ end
111
+
112
+ def start_master
113
+ @socket = "/tmp/test_queue_#{$$}_#{object_id}.sock"
114
+ FileUtils.rm(@socket) if File.exists?(@socket)
115
+ @server = UNIXServer.new(@socket)
116
+ end
117
+
118
+ def stop_master
119
+ FileUtils.rm_f(@socket) if @socket
120
+ @server.close rescue nil if @server
121
+ @socket = @server = nil
122
+ end
123
+
124
+ def spawn_workers
125
+ @concurrency.times do |i|
126
+ pid = fork do
127
+ @server.close
128
+ after_fork(i)
129
+ exit! run_worker(iterator = Iterator.new(@socket)) || 0
130
+ end
131
+
132
+ @workers[pid] = Worker.new(pid, i)
133
+ end
134
+ end
135
+
136
+ def after_fork(num)
137
+ srand
138
+
139
+ output = File.open("/tmp/test_queue_worker_#{$$}_output", 'w')
140
+ output.sync = true
141
+
142
+ $stdout.reopen(output)
143
+ $stderr.reopen($stdout)
144
+
145
+ $0 = "ruby test-queue worker [#{num}]"
146
+ puts
147
+ puts "==> Starting #$0 (#{Process.pid})"
148
+ puts
149
+ end
150
+
151
+ def run_worker(iterator)
152
+ iterator.each do |item|
153
+ puts " #{item.inspect}"
154
+ end
155
+
156
+ return 0 # exit status
157
+ end
158
+
159
+ def summarize_worker(worker)
160
+ num_tests = ''
161
+ failures = ''
162
+
163
+ [ num_tests, failures ]
164
+ end
165
+
166
+ def cleanup_worker
167
+ if pid = Process.waitpid and worker = @workers.delete(pid)
168
+ @completed << worker
169
+ worker.status = $?
170
+ worker.end_time = Time.now
171
+
172
+ if File.exists?(file = "/tmp/test_queue_worker_#{pid}_output")
173
+ worker.output = IO.binread(file)
174
+ puts worker.output
175
+ FileUtils.rm(file)
176
+ end
177
+
178
+ if File.exists?(file = "/tmp/test_queue_worker_#{pid}_stats")
179
+ worker.stats = Marshal.load(IO.binread(file))
180
+ FileUtils.rm(file)
181
+ end
182
+ end
183
+ end
184
+
185
+ def distribute_queue
186
+ until @queue.empty?
187
+ IO.select([@server], nil, nil, nil)
188
+
189
+ sock = @server.accept
190
+ sock.write(Marshal.dump(@queue.shift))
191
+ sock.close
192
+ end
193
+ ensure
194
+ stop_master
195
+
196
+ until @workers.empty?
197
+ cleanup_worker
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,69 @@
1
+ require 'test_queue/runner'
2
+ require 'minitest/unit'
3
+
4
+ class MiniTestQueueRunner < MiniTest::Unit
5
+ def _run_suites(*)
6
+ self.class.output = $stdout
7
+ super
8
+ end
9
+
10
+ def _run_anything(*)
11
+ ret = super
12
+ output.puts
13
+ ret
14
+ end
15
+
16
+ def _run_suite(suite, type)
17
+ output.print ' '
18
+ output.print suite
19
+ output.print ': '
20
+
21
+ start = Time.now
22
+ ret = super
23
+ diff = Time.now - start
24
+
25
+ output.puts(" <%.3f>" % diff)
26
+ ret
27
+ end
28
+
29
+ self.runner = self.new
30
+ self.output = StringIO.new
31
+ end
32
+
33
+ class MiniTest::Unit::TestCase
34
+ class << self
35
+ attr_accessor :test_suites
36
+
37
+ def original_test_suites
38
+ @@test_suites.keys.reject{ |s| s.test_methods.empty? }
39
+ end
40
+ end
41
+ end
42
+
43
+ module TestQueue
44
+ class Runner
45
+ class MiniTest < Runner
46
+ def initialize
47
+ super(::MiniTest::Unit::TestCase.original_test_suites.sort_by{ |s| -(stats[s.to_s] || 0) })
48
+ end
49
+
50
+ def run_worker(iterator)
51
+ ::MiniTest::Unit::TestCase.test_suites = iterator
52
+ ::MiniTest::Unit.new.run
53
+ end
54
+
55
+ def summarize_worker(worker)
56
+ worker.stats.each do |s, val|
57
+ stats[s.to_s] = val
58
+ end
59
+
60
+ num_tests = worker.lines.grep(/ errors?, /).first
61
+ failures = worker.lines.select{ |line|
62
+ line if (line =~ /^Finished/) ... (line =~ / errors?, /)
63
+ }[1..-2].join("\n").strip
64
+
65
+ [ num_tests, failures ]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,65 @@
1
+ require 'test_queue/runner'
2
+ require 'rspec/core'
3
+
4
+ module RSpec::Core
5
+ class QueueRunner < CommandLine
6
+ def initialize
7
+ super(ARGV)
8
+ end
9
+
10
+ def example_groups
11
+ @options.configure(@configuration)
12
+ @configuration.load_spec_files
13
+ @world.announce_filters
14
+ @world.example_groups
15
+ end
16
+
17
+ def run_each(iterator)
18
+ @configuration.error_stream = $stderr
19
+ @configuration.output_stream = $stdout
20
+
21
+ @configuration.reporter.report(0, @configuration.randomize? ? @configuration.seed : nil) do |reporter|
22
+ begin
23
+ @configuration.run_hook(:before, :suite)
24
+ iterator.map {|g|
25
+ print " #{g.description}: "
26
+ start = Time.now
27
+ ret = g.run(reporter)
28
+ diff = Time.now-start
29
+ puts(" <%.3f>" % diff)
30
+
31
+ ret
32
+ }.all? ? 0 : @configuration.failure_exit_code
33
+ ensure
34
+ @configuration.run_hook(:after, :suite)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ module TestQueue
42
+ class Runner
43
+ class RSpec < Runner
44
+ def initialize
45
+ @rspec = ::RSpec::Core::QueueRunner.new
46
+ super(@rspec.example_groups.sort_by{ |s| -(stats[s.description] || 0) })
47
+ end
48
+
49
+ def run_worker(iterator)
50
+ @rspec.run_each(iterator)
51
+ end
52
+
53
+ def summarize_worker(worker)
54
+ worker.stats.each do |s, val|
55
+ stats[s.description] = val
56
+ end
57
+
58
+ num_tests = worker.lines.grep(/ examples?, /).first
59
+ failures = worker.output[/^Failures:\n\n(.*)\n^Finished/m, 1]
60
+
61
+ [ num_tests, failures ]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,78 @@
1
+ require 'test_queue'
2
+ require 'test_queue/runner'
3
+
4
+ module TestQueue
5
+ class Runner
6
+ class Sample < Runner
7
+ def spawn_workers
8
+ puts "Spawning #@concurrency workers"
9
+ super
10
+ end
11
+
12
+ def after_fork(num)
13
+ puts " -- worker #{num} booted as pid #{$$}"
14
+ super
15
+ end
16
+
17
+ def run_worker(iterator)
18
+ sum = 0
19
+ iterator.each do |item|
20
+ puts " #{item.inspect}"
21
+ sum += item
22
+ end
23
+ sum
24
+ end
25
+
26
+ def summarize_worker(worker)
27
+ stats.update(worker.stats)
28
+
29
+ summary = worker.output.scan(/^\s*(\d+)/).join(', ')
30
+ failures = ''
31
+
32
+ [ summary, failures ]
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ if __FILE__ == $0
39
+ TestQueue::Runner::Sample.new(Array(1..10)).execute
40
+ end
41
+
42
+ __END__
43
+
44
+ Spawning 4 workers
45
+ -- worker 0 booted as pid 40406
46
+ -- worker 1 booted as pid 40407
47
+ -- worker 2 booted as pid 40408
48
+ -- worker 3 booted as pid 40409
49
+
50
+ ==> Starting ruby test-queue worker [1] (40407)
51
+
52
+ 2
53
+ 5
54
+ 8
55
+
56
+ ==> Starting ruby test-queue worker [3] (40409)
57
+
58
+
59
+ ==> Starting ruby test-queue worker [2] (40408)
60
+
61
+ 3
62
+ 6
63
+ 9
64
+
65
+ ==> Starting ruby test-queue worker [0] (40406)
66
+
67
+ 1
68
+ 4
69
+ 7
70
+ 10
71
+
72
+ ==> Summary
73
+
74
+ [1] 2, 5, 8 in 0.0024s (pid 40407 exit 15)
75
+ [3] in 0.0036s (pid 40409 exit 0)
76
+ [2] 3, 6, 9 in 0.0038s (pid 40408 exit 18)
77
+ [0] 1, 4, 7, 10 in 0.0044s (pid 40406 exit 22)
78
+
@@ -0,0 +1,20 @@
1
+ spec = Gem::Specification.new do |s|
2
+ s.name = 'test-queue'
3
+ s.version = '0.1.0'
4
+ s.summary = 'parallel test runner'
5
+
6
+ s.homepage = "http://github.com/tmm1/test-queue"
7
+
8
+ s.authors = ["Aman Gupta"]
9
+ s.email = "ruby@tmm1.net"
10
+
11
+ s.has_rdoc = false
12
+ s.bindir = 'bin'
13
+ s.executables << 'rspec-queue'
14
+ s.executables << 'minitest-queue'
15
+
16
+ s.add_development_dependency 'rspec'
17
+ s.add_development_dependency 'minitest'
18
+
19
+ s.files = `git ls-files`.split("\n")
20
+ end
@@ -0,0 +1,21 @@
1
+ require 'rspec'
2
+
3
+ describe 'RSpecEqual' do
4
+ it 'checks equality' do
5
+ 1.should equal(1)
6
+ end
7
+ end
8
+
9
+ describe 'RSpecSleep' do
10
+ it 'sleeps' do
11
+ start = Time.now
12
+ sleep 0.25
13
+ (Time.now-start).should be_within(0.02).of(0.25)
14
+ end
15
+ end
16
+
17
+ describe 'RSpecFailure' do
18
+ it 'fails' do
19
+ :foo.should eq(:bar)
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ require 'minitest/unit'
2
+
3
+ class MiniTestEqual < MiniTest::Unit::TestCase
4
+ def test_equal
5
+ assert_equal 1, 1
6
+ end
7
+ end
8
+
9
+ class MiniTestSleep < MiniTest::Unit::TestCase
10
+ def test_sleep
11
+ start = Time.now
12
+ sleep 0.25
13
+ assert_in_delta Time.now-start, 0.25, 0.02
14
+ end
15
+ end
16
+
17
+ class MiniTestFailure < MiniTest::Unit::TestCase
18
+ def test_fail
19
+ assert_equal 0, 1
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: test-queue
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Aman Gupta
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2013-04-24 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: rspec
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ hash: 3
29
+ segments:
30
+ - 0
31
+ version: "0"
32
+ type: :development
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: minitest
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ none: false
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ hash: 3
43
+ segments:
44
+ - 0
45
+ version: "0"
46
+ type: :development
47
+ version_requirements: *id002
48
+ description:
49
+ email: ruby@tmm1.net
50
+ executables:
51
+ - rspec-queue
52
+ - minitest-queue
53
+ extensions: []
54
+
55
+ extra_rdoc_files: []
56
+
57
+ files:
58
+ - Gemfile
59
+ - Gemfile.lock
60
+ - README.md
61
+ - bin/minitest-queue
62
+ - bin/rspec-queue
63
+ - lib/test-queue.rb
64
+ - lib/test_queue.rb
65
+ - lib/test_queue/iterator.rb
66
+ - lib/test_queue/runner.rb
67
+ - lib/test_queue/runner/minitest.rb
68
+ - lib/test_queue/runner/rspec.rb
69
+ - lib/test_queue/runner/sample.rb
70
+ - test-queue.gemspec
71
+ - test/sample_spec.rb
72
+ - test/sample_test.rb
73
+ homepage: http://github.com/tmm1/test-queue
74
+ licenses: []
75
+
76
+ post_install_message:
77
+ rdoc_options: []
78
+
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ hash: 3
87
+ segments:
88
+ - 0
89
+ version: "0"
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ none: false
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ hash: 3
96
+ segments:
97
+ - 0
98
+ version: "0"
99
+ requirements: []
100
+
101
+ rubyforge_project:
102
+ rubygems_version: 1.8.24
103
+ signing_key:
104
+ specification_version: 3
105
+ summary: parallel test runner
106
+ test_files: []
107
+
108
+ has_rdoc: false