ci-queue 0.6.0 → 0.7.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 +4 -4
- data/README.md +48 -0
- data/exe/minitest-queue +4 -0
- data/lib/ci/queue.rb +28 -0
- data/lib/ci/queue/configuration.rb +46 -0
- data/lib/ci/queue/file.rb +8 -2
- data/lib/ci/queue/index.rb +20 -0
- data/lib/ci/queue/output_helpers.rb +64 -0
- data/lib/ci/queue/redis.rb +9 -2
- data/lib/ci/queue/redis/base.rb +20 -9
- data/lib/ci/queue/redis/retry.rb +7 -7
- data/lib/ci/queue/redis/supervisor.rb +19 -3
- data/lib/ci/queue/redis/worker.rb +40 -26
- data/lib/ci/queue/static.rb +43 -12
- data/lib/ci/queue/version.rb +1 -1
- data/lib/minitest/queue.rb +1 -1
- data/lib/minitest/queue/runner.rb +243 -0
- data/lib/minitest/reporters/failure_formatter.rb +0 -1
- data/lib/minitest/reporters/queue_reporter.rb +4 -2
- data/lib/minitest/reporters/redis_reporter.rb +10 -6
- metadata +11 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 97f69595ec09509df32703213fd2d882db14f017
|
4
|
+
data.tar.gz: cae08e13e586bd5c7cc8927057225084b0ee0d74
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 742b3d39caf903b480950a86eafb687776e8b4d1a9465d28e1ce29ff37d3242f3127a40d7115e51eddec03b6e22322cef0108ef2a64f6a5744b642b3e1b14ecf
|
7
|
+
data.tar.gz: 8f753085f6408c98e698c0a75c06b77a427e066cbad9aa7c1db102613cdcf4b6d25d27e93145e86928bd5ae956b54adeb44245f36ee2ceee3eb3e9646c45363b
|
data/README.md
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
## Installation
|
2
|
+
|
3
|
+
Add this line to your application's Gemfile:
|
4
|
+
|
5
|
+
```ruby
|
6
|
+
gem 'ci-queue'
|
7
|
+
```
|
8
|
+
|
9
|
+
And then execute:
|
10
|
+
|
11
|
+
$ bundle
|
12
|
+
|
13
|
+
Or install it yourself as:
|
14
|
+
|
15
|
+
$ gem install ci-queue
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
### Supported CI providers
|
20
|
+
|
21
|
+
`ci-queue` automatically infers most of its configuration if ran on one of the following CI providers:
|
22
|
+
|
23
|
+
- Buildkite
|
24
|
+
- CircleCI
|
25
|
+
- Travis
|
26
|
+
|
27
|
+
If you are using another CI system, please refer to the command usage message.
|
28
|
+
|
29
|
+
### Minitest
|
30
|
+
|
31
|
+
Assuming you use one of the supported CI providers, the command can be as simple as:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
minitest-queue --queue redis://example.com run -Itest test/**/*_test.rb
|
35
|
+
```
|
36
|
+
|
37
|
+
Additionally you can configure the requeue settings (see main README) with `--max-requeues` and `--requeue-tolerance`.
|
38
|
+
|
39
|
+
|
40
|
+
If you'd like to centralize the error reporting you can do so with:
|
41
|
+
|
42
|
+
```
|
43
|
+
minitest-queue --queue redis://example.com --timeout 600 report
|
44
|
+
```
|
45
|
+
|
46
|
+
### RSpec
|
47
|
+
|
48
|
+
The RSpec integration is not implemented yet.
|
data/exe/minitest-queue
ADDED
data/lib/ci/queue.rb
CHANGED
@@ -1,3 +1,31 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'cgi'
|
3
|
+
|
1
4
|
require 'ci/queue/version'
|
5
|
+
require 'ci/queue/output_helpers'
|
6
|
+
require 'ci/queue/index'
|
7
|
+
require 'ci/queue/configuration'
|
2
8
|
require 'ci/queue/static'
|
3
9
|
require 'ci/queue/file'
|
10
|
+
|
11
|
+
module CI
|
12
|
+
module Queue
|
13
|
+
extend self
|
14
|
+
|
15
|
+
def from_uri(url, config)
|
16
|
+
uri = URI(url)
|
17
|
+
implementation = case uri.scheme
|
18
|
+
when 'list'
|
19
|
+
Static
|
20
|
+
when 'file', nil
|
21
|
+
File
|
22
|
+
when 'redis'
|
23
|
+
require 'ci/queue/redis'
|
24
|
+
Redis
|
25
|
+
else
|
26
|
+
raise ArgumentError, "Don't know how to handle #{uri.scheme} URLs"
|
27
|
+
end
|
28
|
+
implementation.from_uri(uri, config)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module CI
|
2
|
+
module Queue
|
3
|
+
class Configuration
|
4
|
+
attr_accessor :timeout, :build_id, :worker_id, :max_requeues, :requeue_tolerance, :namespace, :seed
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def from_env(env)
|
8
|
+
new(
|
9
|
+
build_id: env['CIRCLE_BUILD_URL'] || env['BUILDKITE_BUILD_ID'] || env['TRAVIS_BUILD_ID'],
|
10
|
+
worker_id: env['CIRCLE_NODE_INDEX'] || env['BUILDKITE_PARALLEL_JOB'],
|
11
|
+
seed: env['CIRCLE_SHA1'] || env['BUILDKITE_COMMIT'] || env['TRAVIS_COMMIT'],
|
12
|
+
)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(
|
17
|
+
timeout: 30, build_id: nil, worker_id: nil, max_requeues: 0, requeue_tolerance: 0,
|
18
|
+
namespace: nil, seed: nil
|
19
|
+
)
|
20
|
+
@namespace = namespace
|
21
|
+
@timeout = timeout
|
22
|
+
@build_id = build_id
|
23
|
+
@worker_id = worker_id
|
24
|
+
@max_requeues = max_requeues
|
25
|
+
@requeue_tolerance = requeue_tolerance
|
26
|
+
@seed = seed
|
27
|
+
end
|
28
|
+
|
29
|
+
def seed
|
30
|
+
@seed || build_id
|
31
|
+
end
|
32
|
+
|
33
|
+
def build_id
|
34
|
+
if namespace
|
35
|
+
"#{namespace}:#{@build_id}"
|
36
|
+
else
|
37
|
+
@build_id
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def global_max_requeues(tests_count)
|
42
|
+
(tests_count * Float(requeue_tolerance)).ceil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/ci/queue/file.rb
CHANGED
@@ -3,8 +3,14 @@ require 'ci/queue/static'
|
|
3
3
|
module CI
|
4
4
|
module Queue
|
5
5
|
class File < Static
|
6
|
-
|
7
|
-
|
6
|
+
class << self
|
7
|
+
def from_uri(uri, config)
|
8
|
+
new(uri.path, config)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(path, *args)
|
13
|
+
super(::File.readlines(path).map(&:strip).reject(&:empty?), *args)
|
8
14
|
end
|
9
15
|
end
|
10
16
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module CI
|
2
|
+
module Queue
|
3
|
+
class Index
|
4
|
+
def initialize(objects, &indexer)
|
5
|
+
@index = objects.map { |o| [indexer.call(o), o] }.to_h
|
6
|
+
@indexer = indexer
|
7
|
+
end
|
8
|
+
|
9
|
+
def fetch(key)
|
10
|
+
@index.fetch(key)
|
11
|
+
end
|
12
|
+
|
13
|
+
def key(value)
|
14
|
+
key = @indexer.call(value)
|
15
|
+
raise KeyError, "value not found: #{value.inspect}" unless @index.key?(key)
|
16
|
+
key
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'ansi'
|
2
|
+
|
3
|
+
module CI
|
4
|
+
module Queue
|
5
|
+
module OutputHelpers
|
6
|
+
include ANSI::Code
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def step(*args)
|
11
|
+
ci_provider.step(*args)
|
12
|
+
end
|
13
|
+
|
14
|
+
def reopen_previous_step
|
15
|
+
ci_provider.reopen_previous_step
|
16
|
+
end
|
17
|
+
|
18
|
+
def close_previous_step
|
19
|
+
ci_provider.close_previous_step
|
20
|
+
end
|
21
|
+
|
22
|
+
def ci_provider
|
23
|
+
@ci_provider ||= if ENV['BUILDKITE']
|
24
|
+
BuildkiteOutput
|
25
|
+
else
|
26
|
+
DefaultOutput
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module DefaultOutput
|
31
|
+
extend self
|
32
|
+
|
33
|
+
def step(title, collapsed: true)
|
34
|
+
puts title
|
35
|
+
end
|
36
|
+
|
37
|
+
def reopen_previous_step
|
38
|
+
# noop
|
39
|
+
end
|
40
|
+
|
41
|
+
def close_previous_step
|
42
|
+
# noop
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module BuildkiteOutput
|
47
|
+
extend self
|
48
|
+
|
49
|
+
def step(title, collapsed: true)
|
50
|
+
prefix = collapsed ? '---' : '+++'
|
51
|
+
puts "#{prefix} #{title}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def reopen_previous_step
|
55
|
+
puts '^^^ +++'
|
56
|
+
end
|
57
|
+
|
58
|
+
def close_previous_step
|
59
|
+
puts '^^^ ---'
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/ci/queue/redis.rb
CHANGED
@@ -10,8 +10,15 @@ module CI
|
|
10
10
|
Error = Class.new(StandardError)
|
11
11
|
LostMaster = Class.new(Error)
|
12
12
|
|
13
|
-
|
14
|
-
|
13
|
+
class << self
|
14
|
+
|
15
|
+
def new(*args)
|
16
|
+
Worker.new(*args)
|
17
|
+
end
|
18
|
+
|
19
|
+
def from_uri(uri, config)
|
20
|
+
new(uri.to_s, config)
|
21
|
+
end
|
15
22
|
end
|
16
23
|
end
|
17
24
|
end
|
data/lib/ci/queue/redis/base.rb
CHANGED
@@ -2,13 +2,14 @@ module CI
|
|
2
2
|
module Queue
|
3
3
|
module Redis
|
4
4
|
class Base
|
5
|
-
def initialize(
|
6
|
-
@
|
7
|
-
@
|
5
|
+
def initialize(redis_url, config)
|
6
|
+
@redis_url = redis_url
|
7
|
+
@redis = ::Redis.new(url: redis_url)
|
8
|
+
@config = config
|
8
9
|
end
|
9
10
|
|
10
|
-
def
|
11
|
-
size == 0
|
11
|
+
def exhausted?
|
12
|
+
queue_initialized? && size == 0
|
12
13
|
end
|
13
14
|
|
14
15
|
def size
|
@@ -22,7 +23,7 @@ module CI
|
|
22
23
|
redis.multi do
|
23
24
|
redis.lrange(key('queue'), 0, -1)
|
24
25
|
redis.zrange(key('running'), 0, -1)
|
25
|
-
end.flatten.reverse
|
26
|
+
end.flatten.reverse.map { |k| index.fetch(k) }
|
26
27
|
end
|
27
28
|
|
28
29
|
def progress
|
@@ -32,8 +33,7 @@ module CI
|
|
32
33
|
def wait_for_master(timeout: 10)
|
33
34
|
return true if master?
|
34
35
|
(timeout * 10 + 1).to_i.times do
|
35
|
-
|
36
|
-
when 'ready', 'finished'
|
36
|
+
if queue_initialized?
|
37
37
|
return true
|
38
38
|
else
|
39
39
|
sleep 0.1
|
@@ -46,14 +46,25 @@ module CI
|
|
46
46
|
redis.scard(key('workers'))
|
47
47
|
end
|
48
48
|
|
49
|
+
def queue_initialized?
|
50
|
+
@queue_initialized ||= begin
|
51
|
+
status = master_status
|
52
|
+
status == 'ready' || status == 'finished'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
49
56
|
private
|
50
57
|
|
51
|
-
attr_reader :redis, :
|
58
|
+
attr_reader :redis, :config, :redis_url
|
52
59
|
|
53
60
|
def key(*args)
|
54
61
|
['build', build_id, *args].join(':')
|
55
62
|
end
|
56
63
|
|
64
|
+
def build_id
|
65
|
+
config.build_id
|
66
|
+
end
|
67
|
+
|
57
68
|
def master_status
|
58
69
|
redis.get(key('master-status'))
|
59
70
|
end
|
data/lib/ci/queue/redis/retry.rb
CHANGED
@@ -2,27 +2,27 @@ module CI
|
|
2
2
|
module Queue
|
3
3
|
module Redis
|
4
4
|
class Retry < Static
|
5
|
-
def initialize(tests, redis
|
5
|
+
def initialize(tests, config, redis:)
|
6
6
|
@redis = redis
|
7
|
-
|
8
|
-
@worker_id = worker_id
|
9
|
-
super(tests, **args)
|
7
|
+
super(tests, config)
|
10
8
|
end
|
11
9
|
|
12
10
|
def minitest_reporters
|
11
|
+
require 'minitest/reporters/queue_reporter'
|
13
12
|
require 'minitest/reporters/redis_reporter'
|
14
13
|
@minitest_reporters ||= [
|
14
|
+
Minitest::Reporters::QueueReporter.new,
|
15
15
|
Minitest::Reporters::RedisReporter::Worker.new(
|
16
16
|
redis: redis,
|
17
|
-
build_id: build_id,
|
18
|
-
worker_id: worker_id,
|
17
|
+
build_id: config.build_id,
|
18
|
+
worker_id: config.worker_id,
|
19
19
|
)
|
20
20
|
]
|
21
21
|
end
|
22
22
|
|
23
23
|
private
|
24
24
|
|
25
|
-
attr_reader :redis
|
25
|
+
attr_reader :redis
|
26
26
|
end
|
27
27
|
end
|
28
28
|
end
|
@@ -6,11 +6,27 @@ module CI
|
|
6
6
|
false
|
7
7
|
end
|
8
8
|
|
9
|
+
def minitest_reporters
|
10
|
+
require 'minitest/reporters/redis_reporter'
|
11
|
+
@reporters ||= [
|
12
|
+
Minitest::Reporters::RedisReporter::Summary.new(
|
13
|
+
build_id: build_id,
|
14
|
+
redis: redis,
|
15
|
+
)
|
16
|
+
]
|
17
|
+
end
|
18
|
+
|
9
19
|
def wait_for_workers
|
10
|
-
return false unless wait_for_master
|
20
|
+
return false unless wait_for_master(timeout: config.timeout)
|
11
21
|
|
12
|
-
|
13
|
-
|
22
|
+
time_left = config.timeout
|
23
|
+
until exhausted? || time_left <= 0
|
24
|
+
sleep 0.1
|
25
|
+
time_left -= 0.1
|
26
|
+
end
|
27
|
+
exhausted?
|
28
|
+
rescue CI::Queue::Redis::LostMaster
|
29
|
+
false
|
14
30
|
end
|
15
31
|
end
|
16
32
|
end
|
@@ -13,15 +13,20 @@ module CI
|
|
13
13
|
class Worker < Base
|
14
14
|
attr_reader :total
|
15
15
|
|
16
|
-
def initialize(
|
16
|
+
def initialize(redis, config)
|
17
17
|
@reserved_test = nil
|
18
|
-
@max_requeues = max_requeues
|
19
|
-
@global_max_requeues = (tests.size * requeue_tolerance).ceil
|
20
18
|
@shutdown_required = false
|
21
|
-
super(redis
|
22
|
-
|
23
|
-
|
24
|
-
|
19
|
+
super(redis, config)
|
20
|
+
end
|
21
|
+
|
22
|
+
def populate(tests, &indexer)
|
23
|
+
@index = Index.new(tests, &indexer)
|
24
|
+
push(tests.map { |t| index.key(t) })
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def populated?
|
29
|
+
!!defined?(@index)
|
25
30
|
end
|
26
31
|
|
27
32
|
def shutdown!
|
@@ -38,9 +43,9 @@ module CI
|
|
38
43
|
|
39
44
|
def poll
|
40
45
|
wait_for_master
|
41
|
-
until shutdown_required? ||
|
46
|
+
until shutdown_required? || exhausted?
|
42
47
|
if test = reserve
|
43
|
-
yield test
|
48
|
+
yield index.fetch(test)
|
44
49
|
else
|
45
50
|
sleep 0.05
|
46
51
|
end
|
@@ -48,19 +53,20 @@ module CI
|
|
48
53
|
rescue ::Redis::BaseConnectionError
|
49
54
|
end
|
50
55
|
|
51
|
-
def retry_queue
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
)
|
56
|
+
def retry_queue
|
57
|
+
log = redis.lrange(key('worker', worker_id, 'queue'), 0, -1).reverse.uniq
|
58
|
+
Retry.new(log, config, redis: redis)
|
59
|
+
end
|
60
|
+
|
61
|
+
def supervisor
|
62
|
+
Supervisor.new(redis_url, config)
|
59
63
|
end
|
60
64
|
|
61
65
|
def minitest_reporters
|
66
|
+
require 'minitest/reporters/queue_reporter'
|
62
67
|
require 'minitest/reporters/redis_reporter'
|
63
68
|
@minitest_reporters ||= [
|
69
|
+
Minitest::Reporters::QueueReporter.new,
|
64
70
|
Minitest::Reporters::RedisReporter::Worker.new(
|
65
71
|
redis: redis,
|
66
72
|
build_id: build_id,
|
@@ -70,30 +76,40 @@ module CI
|
|
70
76
|
end
|
71
77
|
|
72
78
|
def acknowledge(test)
|
73
|
-
|
79
|
+
test_key = index.key(test)
|
80
|
+
raise_on_mismatching_test(test_key)
|
74
81
|
eval_script(
|
75
82
|
:acknowledge,
|
76
83
|
keys: [key('running'), key('processed')],
|
77
|
-
argv: [
|
84
|
+
argv: [test_key],
|
78
85
|
) == 1
|
79
86
|
end
|
80
87
|
|
81
88
|
def requeue(test, offset: Redis.requeue_offset)
|
82
|
-
|
89
|
+
test_key = index.key(test)
|
90
|
+
raise_on_mismatching_test(test_key)
|
83
91
|
|
84
92
|
requeued = eval_script(
|
85
93
|
:requeue,
|
86
94
|
keys: [key('processed'), key('requeues-count'), key('queue'), key('running')],
|
87
|
-
argv: [max_requeues, global_max_requeues,
|
95
|
+
argv: [config.max_requeues, config.global_max_requeues(total), test_key, offset],
|
88
96
|
) == 1
|
89
97
|
|
90
|
-
@reserved_test =
|
98
|
+
@reserved_test = test_key unless requeued
|
91
99
|
requeued
|
92
100
|
end
|
93
101
|
|
94
102
|
private
|
95
103
|
|
96
|
-
attr_reader :
|
104
|
+
attr_reader :index
|
105
|
+
|
106
|
+
def worker_id
|
107
|
+
config.worker_id
|
108
|
+
end
|
109
|
+
|
110
|
+
def timeout
|
111
|
+
config.timeout
|
112
|
+
end
|
97
113
|
|
98
114
|
def raise_on_mismatching_test(test)
|
99
115
|
if @reserved_test == test
|
@@ -112,9 +128,6 @@ module CI
|
|
112
128
|
@reserved_test = (try_to_reserve_lost_test || try_to_reserve_test)
|
113
129
|
end
|
114
130
|
|
115
|
-
RESERVE_TEST = %{
|
116
|
-
}
|
117
|
-
|
118
131
|
def try_to_reserve_test
|
119
132
|
eval_script(
|
120
133
|
:reserve,
|
@@ -133,6 +146,7 @@ module CI
|
|
133
146
|
|
134
147
|
def push(tests)
|
135
148
|
@total = tests.size
|
149
|
+
|
136
150
|
if @master = redis.setnx(key('master-status'), 'setup')
|
137
151
|
redis.multi do
|
138
152
|
redis.lpush(key('queue'), tests)
|
data/lib/ci/queue/static.rb
CHANGED
@@ -1,18 +1,48 @@
|
|
1
1
|
module CI
|
2
2
|
module Queue
|
3
3
|
class Static
|
4
|
+
class << self
|
5
|
+
def from_uri(uri, config)
|
6
|
+
tests = uri.opaque.split(':').map { |t| CGI.unescape(t) }
|
7
|
+
new(tests, config)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
4
11
|
attr_reader :progress, :total
|
5
12
|
|
6
|
-
def initialize(tests,
|
13
|
+
def initialize(tests, config)
|
7
14
|
@queue = tests
|
15
|
+
@config = config
|
8
16
|
@progress = 0
|
9
17
|
@total = tests.size
|
10
|
-
|
11
|
-
|
18
|
+
end
|
19
|
+
|
20
|
+
def minitest_reporters
|
21
|
+
require 'minitest/reporters/queue_reporter'
|
22
|
+
@minitest_reporters ||= [
|
23
|
+
Minitest::Reporters::QueueReporter.new,
|
24
|
+
]
|
25
|
+
end
|
26
|
+
|
27
|
+
def supervisor
|
28
|
+
raise NotImplementedError, "This type of queue can't be supervised"
|
29
|
+
end
|
30
|
+
|
31
|
+
def retry_queue
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
def populate(tests, &indexer)
|
36
|
+
@index = Index.new(tests, &indexer)
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def populated?
|
41
|
+
!!defined?(@index)
|
12
42
|
end
|
13
43
|
|
14
44
|
def to_a
|
15
|
-
@queue.
|
45
|
+
@queue.map { |i| index.fetch(i) }
|
16
46
|
end
|
17
47
|
|
18
48
|
def size
|
@@ -21,12 +51,12 @@ module CI
|
|
21
51
|
|
22
52
|
def poll
|
23
53
|
while test = @queue.shift
|
24
|
-
yield test
|
54
|
+
yield index.fetch(test)
|
25
55
|
@progress += 1
|
26
56
|
end
|
27
57
|
end
|
28
58
|
|
29
|
-
def
|
59
|
+
def exhausted?
|
30
60
|
@queue.empty?
|
31
61
|
end
|
32
62
|
|
@@ -35,18 +65,19 @@ module CI
|
|
35
65
|
end
|
36
66
|
|
37
67
|
def requeue(test)
|
38
|
-
|
39
|
-
|
40
|
-
|
68
|
+
key = index.key(test)
|
69
|
+
return false unless should_requeue?(key)
|
70
|
+
requeues[key] += 1
|
71
|
+
@queue.unshift(index.key(test))
|
41
72
|
true
|
42
73
|
end
|
43
74
|
|
44
75
|
private
|
45
76
|
|
46
|
-
attr_reader :
|
77
|
+
attr_reader :index, :config
|
47
78
|
|
48
|
-
def should_requeue?(
|
49
|
-
requeues[
|
79
|
+
def should_requeue?(key)
|
80
|
+
requeues[key] < config.max_requeues && requeues.values.inject(0, :+) < config.global_max_requeues(total)
|
50
81
|
end
|
51
82
|
|
52
83
|
def requeues
|
data/lib/ci/queue/version.rb
CHANGED
data/lib/minitest/queue.rb
CHANGED
@@ -0,0 +1,243 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'minitest/queue'
|
3
|
+
require 'ci/queue'
|
4
|
+
require 'digest/md5'
|
5
|
+
|
6
|
+
module Minitest
|
7
|
+
module Queue
|
8
|
+
class Runner
|
9
|
+
include ::CI::Queue::OutputHelpers
|
10
|
+
|
11
|
+
Error = Class.new(StandardError)
|
12
|
+
MissingParameter = Class.new(Error)
|
13
|
+
|
14
|
+
def self.invoke(argv)
|
15
|
+
new(argv).run!
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(argv)
|
19
|
+
@queue_config = CI::Queue::Configuration.from_env(ENV)
|
20
|
+
@command, @argv = parse(argv)
|
21
|
+
end
|
22
|
+
|
23
|
+
def run!
|
24
|
+
invalid_usage!("No command given") if command.nil?
|
25
|
+
invalid_usage!('Missing queue URL') unless queue_url
|
26
|
+
|
27
|
+
@queue = CI::Queue.from_uri(queue_url, queue_config)
|
28
|
+
|
29
|
+
method = "#{command}_command"
|
30
|
+
if respond_to?(method)
|
31
|
+
public_send(method)
|
32
|
+
else
|
33
|
+
invalid_usage!("Unknown command: #{command}")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def retry_command
|
38
|
+
self.queue = queue.retry_queue
|
39
|
+
run_command
|
40
|
+
end
|
41
|
+
|
42
|
+
def run_command
|
43
|
+
set_load_path
|
44
|
+
Minitest.queue = queue
|
45
|
+
trap('TERM') { Minitest.queue.shutdown! }
|
46
|
+
trap('INT') { Minitest.queue.shutdown! }
|
47
|
+
load_tests
|
48
|
+
populate_queue
|
49
|
+
# Let minitest's at_exit hook trigger
|
50
|
+
end
|
51
|
+
|
52
|
+
def report_command
|
53
|
+
supervisor = begin
|
54
|
+
queue.supervisor
|
55
|
+
rescue NotImplementedError => error
|
56
|
+
abort! error.message
|
57
|
+
end
|
58
|
+
|
59
|
+
step("Waiting for workers to complete")
|
60
|
+
|
61
|
+
unless supervisor.wait_for_workers
|
62
|
+
unless supervisor.queue_initialized?
|
63
|
+
abort! "No master was elected. Did all workers crash?"
|
64
|
+
end
|
65
|
+
|
66
|
+
unless supervisor.exhausted?
|
67
|
+
abort! "#{supervisor.size} tests weren't run."
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
success = supervisor.minitest_reporters.all?(&:success?)
|
72
|
+
supervisor.minitest_reporters.each do |reporter|
|
73
|
+
reporter.report
|
74
|
+
end
|
75
|
+
|
76
|
+
STDOUT.flush
|
77
|
+
exit! success ? 0 : 1
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
attr_reader :queue_config, :options, :command, :argv
|
83
|
+
attr_accessor :queue, :queue_url, :load_paths
|
84
|
+
|
85
|
+
def populate_queue
|
86
|
+
Minitest.queue.populate(shuffle(Minitest.loaded_tests), &:to_s) # TODO: stop serializing
|
87
|
+
end
|
88
|
+
|
89
|
+
def set_load_path
|
90
|
+
if paths = load_paths
|
91
|
+
paths.split(':').reverse.each do |path|
|
92
|
+
$LOAD_PATH.unshift(File.expand_path(path))
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def load_tests
|
98
|
+
argv.sort.each do |f|
|
99
|
+
require File.expand_path(f)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse(argv)
|
104
|
+
parser.parse!(argv)
|
105
|
+
command = argv.shift
|
106
|
+
return command, argv
|
107
|
+
end
|
108
|
+
|
109
|
+
def parser
|
110
|
+
@parser ||= OptionParser.new do |opts|
|
111
|
+
opts.banner = "Usage: minitest-queue [options] COMMAND [ARGS]"
|
112
|
+
|
113
|
+
opts.separator ""
|
114
|
+
opts.separator "Example: minitest-queue -Itest --queue redis://example.com run test/**/*_test.rb"
|
115
|
+
|
116
|
+
opts.separator ""
|
117
|
+
opts.separator "GLOBAL OPTIONS"
|
118
|
+
|
119
|
+
|
120
|
+
help = split_heredoc(<<-EOS)
|
121
|
+
URL of the queue, e.g. redis://example.com.
|
122
|
+
Defaults to $CI_QUEUE_URL if set.
|
123
|
+
EOS
|
124
|
+
opts.separator ""
|
125
|
+
opts.on('--queue URL', *help) do |url|
|
126
|
+
self.queue_url = url
|
127
|
+
end
|
128
|
+
|
129
|
+
help = split_heredoc(<<-EOS)
|
130
|
+
Unique identifier for the workload. All workers working on the same suite of tests must have the same build identifier.
|
131
|
+
If the build is tried again, or another revision is built, this value must be different.
|
132
|
+
It's automatically inferred on Buildkite, CircleCI and Travis.
|
133
|
+
EOS
|
134
|
+
opts.separator ""
|
135
|
+
opts.on('--build BUILD_ID', *help) do |build_id|
|
136
|
+
queue_config.build_id = build_id
|
137
|
+
end
|
138
|
+
|
139
|
+
help = split_heredoc(<<-EOS)
|
140
|
+
Optional. Sets a prefix for the build id in case a single CI build runs multiple independent test suites.
|
141
|
+
Example: --namespace integration
|
142
|
+
EOS
|
143
|
+
opts.separator ""
|
144
|
+
opts.on('--namespace NAMESPACE', *help) do |namespace|
|
145
|
+
queue_config.namespace = namespace
|
146
|
+
end
|
147
|
+
|
148
|
+
opts.separator ""
|
149
|
+
opts.separator "COMMANDS"
|
150
|
+
opts.separator ""
|
151
|
+
opts.separator " run [TEST_FILES...]: Participate in leader election, and then work off the test queue."
|
152
|
+
|
153
|
+
help = split_heredoc(<<-EOS)
|
154
|
+
Specify a timeout after which if a test haven't completed, it will be picked up by another worker.
|
155
|
+
It is very important to set this vlaue higher than the slowest test in the suite, otherwise performance will be impacted.
|
156
|
+
Defaults to 30 seconds.
|
157
|
+
EOS
|
158
|
+
opts.separator ""
|
159
|
+
opts.on('--timeout TIMEOUT', *help) do |timeout|
|
160
|
+
queue_config.timeout = Float(timeout)
|
161
|
+
end
|
162
|
+
|
163
|
+
help = split_heredoc(<<-EOS)
|
164
|
+
Specify $LOAD_PATH directory, similar to Ruby's -I
|
165
|
+
EOS
|
166
|
+
opts.separator ""
|
167
|
+
opts.on('-IPATHS', *help) do |paths|
|
168
|
+
self.load_paths = paths
|
169
|
+
end
|
170
|
+
|
171
|
+
help = split_heredoc(<<-EOS)
|
172
|
+
Sepcify a seed used to shuffle the test suite.
|
173
|
+
On Buildkite, CircleCI and Travis, the commit revision will be used by default.
|
174
|
+
EOS
|
175
|
+
opts.separator ""
|
176
|
+
opts.on('--seed SEED', *help) do |seed|
|
177
|
+
queue_config.seed = seed
|
178
|
+
end
|
179
|
+
|
180
|
+
help = split_heredoc(<<-EOS)
|
181
|
+
A unique identifier for this worker, It must be consistent to allow retries.
|
182
|
+
If not specified, retries won't be available.
|
183
|
+
It's automatically inferred on Buildkite and CircleCI.
|
184
|
+
EOS
|
185
|
+
opts.separator ""
|
186
|
+
opts.on('--worker WORKER_ID', *help) do |worker_id|
|
187
|
+
queue_config.worker_id = worker_id
|
188
|
+
end
|
189
|
+
|
190
|
+
help = split_heredoc(<<-EOS)
|
191
|
+
Defines how many time a single test can be requeued.
|
192
|
+
Defaults to 0.
|
193
|
+
EOS
|
194
|
+
opts.separator ""
|
195
|
+
opts.on('--max-requeues MAX') do |max|
|
196
|
+
queue_config.max_requeues = Integer(max)
|
197
|
+
end
|
198
|
+
|
199
|
+
help = split_heredoc(<<-EOS)
|
200
|
+
Defines how many requeues can happen overall, based on the test suite size. e.g 0.05 for 5%.
|
201
|
+
Defaults to 0.
|
202
|
+
EOS
|
203
|
+
opts.separator ""
|
204
|
+
opts.on('--requeue-tolerance RATIO', *help) do |ratio|
|
205
|
+
queue_config.requeue_tolerance = Float(ratio)
|
206
|
+
end
|
207
|
+
|
208
|
+
opts.separator ""
|
209
|
+
opts.separator " retry: Replays a previous run in the same order."
|
210
|
+
|
211
|
+
opts.separator ""
|
212
|
+
opts.separator " report: Wait for all workers to complete and summarize the test failures."
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def split_heredoc(string)
|
217
|
+
string.lines.map(&:strip)
|
218
|
+
end
|
219
|
+
|
220
|
+
def shuffle(tests)
|
221
|
+
random = Random.new(Digest::MD5.hexdigest(queue_config.seed).to_i(16))
|
222
|
+
tests.shuffle(random: random)
|
223
|
+
end
|
224
|
+
|
225
|
+
def queue_url
|
226
|
+
@queue_url || ENV['CI_QUEUE_URL']
|
227
|
+
end
|
228
|
+
|
229
|
+
def invalid_usage!(message)
|
230
|
+
reopen_previous_step
|
231
|
+
puts red(message)
|
232
|
+
puts parser
|
233
|
+
exit! 1 # exit! is required to avoid minitest at_exit callback
|
234
|
+
end
|
235
|
+
|
236
|
+
def abort!(message)
|
237
|
+
reopen_previous_step
|
238
|
+
puts red(message)
|
239
|
+
exit! 1 # exit! is required to avoid minitest at_exit callback
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
@@ -1,9 +1,10 @@
|
|
1
|
+
require 'ci/queue/output_helpers'
|
1
2
|
require 'minitest/reporters'
|
2
3
|
|
3
4
|
module Minitest
|
4
5
|
module Reporters
|
5
6
|
class QueueReporter < BaseReporter
|
6
|
-
include
|
7
|
+
include ::CI::Queue::OutputHelpers
|
7
8
|
attr_accessor :requeues
|
8
9
|
|
9
10
|
def initialize(*)
|
@@ -20,9 +21,10 @@ module Minitest
|
|
20
21
|
private
|
21
22
|
|
22
23
|
def print_report
|
24
|
+
reopen_previous_step if failures > 0 || errors > 0
|
23
25
|
success = failures.zero? && errors.zero?
|
24
26
|
failures_count = "#{failures} failures, #{errors} errors,"
|
25
|
-
|
27
|
+
step [
|
26
28
|
'Ran %d tests, %d assertions,' % [count, assertions],
|
27
29
|
success ? green(failures_count) : red(failures_count),
|
28
30
|
yellow("#{skips} skips, #{requeues} requeues"),
|
@@ -89,16 +89,20 @@ module Minitest
|
|
89
89
|
end
|
90
90
|
|
91
91
|
class Summary < Base
|
92
|
-
include
|
92
|
+
include ::CI::Queue::OutputHelpers
|
93
93
|
|
94
|
-
def report
|
95
|
-
|
94
|
+
def report
|
95
|
+
puts aggregates
|
96
96
|
errors = error_reports
|
97
|
-
|
97
|
+
puts errors
|
98
98
|
|
99
99
|
errors.empty?
|
100
100
|
end
|
101
101
|
|
102
|
+
def success?
|
103
|
+
errors == 0 && failures == 0
|
104
|
+
end
|
105
|
+
|
102
106
|
def record(*)
|
103
107
|
raise NotImplementedError
|
104
108
|
end
|
@@ -133,12 +137,12 @@ module Minitest
|
|
133
137
|
success = failures.zero? && errors.zero?
|
134
138
|
failures_count = "#{failures} failures, #{errors} errors,"
|
135
139
|
|
136
|
-
[
|
140
|
+
step([
|
137
141
|
'Ran %d tests, %d assertions,' % [processed, assertions],
|
138
142
|
success ? green(failures_count) : red(failures_count),
|
139
143
|
yellow("#{skips} skips, #{requeues} requeues"),
|
140
144
|
'in %.2fs (aggregated)' % total_time,
|
141
|
-
].join(' ')
|
145
|
+
].join(' '), collapsed: success)
|
142
146
|
end
|
143
147
|
|
144
148
|
def fetch_summary
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ci-queue
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jean Boussier
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-11-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -97,12 +97,14 @@ dependencies:
|
|
97
97
|
description: To parallelize your CI without having to balance your tests
|
98
98
|
email:
|
99
99
|
- jean.boussier@shopify.com
|
100
|
-
executables:
|
100
|
+
executables:
|
101
|
+
- minitest-queue
|
101
102
|
extensions: []
|
102
103
|
extra_rdoc_files: []
|
103
104
|
files:
|
104
105
|
- ".gitignore"
|
105
106
|
- Gemfile
|
107
|
+
- README.md
|
106
108
|
- Rakefile
|
107
109
|
- bin/bundler
|
108
110
|
- bin/console
|
@@ -110,8 +112,12 @@ files:
|
|
110
112
|
- bin/setup
|
111
113
|
- ci-queue.gemspec
|
112
114
|
- dev.yml
|
115
|
+
- exe/minitest-queue
|
113
116
|
- lib/ci/queue.rb
|
117
|
+
- lib/ci/queue/configuration.rb
|
114
118
|
- lib/ci/queue/file.rb
|
119
|
+
- lib/ci/queue/index.rb
|
120
|
+
- lib/ci/queue/output_helpers.rb
|
115
121
|
- lib/ci/queue/redis.rb
|
116
122
|
- lib/ci/queue/redis/acknowledge.lua
|
117
123
|
- lib/ci/queue/redis/base.rb
|
@@ -124,6 +130,7 @@ files:
|
|
124
130
|
- lib/ci/queue/static.rb
|
125
131
|
- lib/ci/queue/version.rb
|
126
132
|
- lib/minitest/queue.rb
|
133
|
+
- lib/minitest/queue/runner.rb
|
127
134
|
- lib/minitest/reporters/failure_formatter.rb
|
128
135
|
- lib/minitest/reporters/order_reporter.rb
|
129
136
|
- lib/minitest/reporters/queue_reporter.rb
|
@@ -148,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
148
155
|
version: '0'
|
149
156
|
requirements: []
|
150
157
|
rubyforge_project:
|
151
|
-
rubygems_version: 2.6.
|
158
|
+
rubygems_version: 2.6.10
|
152
159
|
signing_key:
|
153
160
|
specification_version: 4
|
154
161
|
summary: Distribute tests over many workers using a queue
|