test-queue 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/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