rspecq 0.3.0 → 0.7.1
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/CHANGELOG.md +34 -1
- data/README.md +47 -3
- data/Rakefile +1 -1
- data/bin/rspecq +40 -5
- data/lib/rspecq/formatters/failure_recorder.rb +15 -6
- data/lib/rspecq/formatters/job_timing_recorder.rb +3 -0
- data/lib/rspecq/formatters/worker_heartbeat_recorder.rb +0 -1
- data/lib/rspecq/queue.rb +69 -6
- data/lib/rspecq/reporter.rb +29 -13
- data/lib/rspecq/version.rb +1 -1
- data/lib/rspecq/worker.rb +52 -17
- metadata +20 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e8bcc40eeeccb4d94e795f524f7f6bce73454a25ebb1907c58523e7e775740a7
|
4
|
+
data.tar.gz: 93d9aa04b32ef60c430c505fb7cd4d147ca2e106be442ebd5f6bd18221db4841
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 80b3eea41c65a3f759c895c0aaf9895c67b4fd2d4c57acb3b8d193fa2522e654735eed33f96a1800a34d5471e5ed83768c2d2c1a31c8a6975c16673f3aaffa79
|
7
|
+
data.tar.gz: dfe2e954fc0d83be7da5bd4c3b24c236950ac6f088ec6a46284954c5e49609c8dea6e15222ccbf5d9d1be353f28671a61e68a04d90bd4b19fb791b2acae685fd
|
data/CHANGELOG.md
CHANGED
@@ -4,6 +4,39 @@ Breaking changes are prefixed with a "[BREAKING]" label.
|
|
4
4
|
|
5
5
|
## master (unreleased)
|
6
6
|
|
7
|
+
## 0.7.1 (2021-04-08)
|
8
|
+
|
9
|
+
- New env variable RSPECQ_REPORTER_RERUN_COMMAND_SKIP. When set, the reporter
|
10
|
+
does not include the flaky test's rerun command.
|
11
|
+
|
12
|
+
## 0.7.0 (2021-04-01)
|
13
|
+
|
14
|
+
- New cli parameter `reproduction`.
|
15
|
+
When passed, primary worker publishes the queue in the same order as passed
|
16
|
+
in the command.
|
17
|
+
- Reporter now includes a reproduction command for flaky tests.
|
18
|
+
|
19
|
+
## 0.6.0 (2021-03-23)
|
20
|
+
|
21
|
+
- New cli parameter `seed`.
|
22
|
+
The seed is passed to the RSpec command.
|
23
|
+
|
24
|
+
## 0.5.0 (2021-02-05)
|
25
|
+
|
26
|
+
### Added
|
27
|
+
|
28
|
+
- New cli parameter `queue_wait_timeout`.
|
29
|
+
It configured the time a queue can wait to be ready. The env equivalent
|
30
|
+
is `RSPECQ_QUEUE_WAIT_TIMEOUT`. [#51](https://github.com/skroutz/rspecq/pull/51)
|
31
|
+
|
32
|
+
## 0.4.0 (2020-10-07)
|
33
|
+
|
34
|
+
### Added
|
35
|
+
|
36
|
+
- Builds can be configured to terminate after a specified number of failures,
|
37
|
+
using the `--fail-fast` option.
|
38
|
+
|
39
|
+
|
7
40
|
## 0.3.0 (2020-10-05)
|
8
41
|
|
9
42
|
### Added
|
@@ -19,7 +52,7 @@ Breaking changes are prefixed with a "[BREAKING]" label.
|
|
19
52
|
## 0.2.2 (2020-09-10)
|
20
53
|
|
21
54
|
### Fixed
|
22
|
-
- Worker would fail if application code was writing to stderr
|
55
|
+
- Worker would fail if application code was writing to stderr
|
23
56
|
[[#35](https://github.com/skroutz/rspecq/pull/35)]
|
24
57
|
|
25
58
|
## 0.2.1 (2020-09-09)
|
data/README.md
CHANGED
@@ -26,8 +26,8 @@ and [ci-queue](https://github.com/Shopify/ci-queue).
|
|
26
26
|
- Handles intermittent worker failures (e.g. network hiccups, faulty hardware etc.)
|
27
27
|
by detecting non-responsive workers and requeing their jobs. See [*Worker failures*](#worker-failures)
|
28
28
|
- Sentry integration for monitoring build-level events. See [*Sentry integration*](#sentry-integration).
|
29
|
-
- [PLANNED] StatsD integration for various build-level metrics and insights.
|
30
29
|
See [#2](https://github.com/skroutz/rspecq/issues/2).
|
30
|
+
- Automatic termination of builds after a certain amount of failures. See [*Fail-fast*](#fail-fast).
|
31
31
|
|
32
32
|
## Usage
|
33
33
|
|
@@ -64,6 +64,7 @@ USAGE:
|
|
64
64
|
OPTIONS:
|
65
65
|
-b, --build ID A unique identifier for the build. Should be common among workers participating in the same build.
|
66
66
|
-w, --worker ID An identifier for the worker. Workers participating in the same build should have distinct IDs.
|
67
|
+
--seed SEED The RSpec seed. Passing the seed can be helpful in many ways i.e reproduction and testing.
|
67
68
|
-r, --redis HOST --redis is deprecated. Use --redis-host or --redis-url instead. Redis host to connect to (default: 127.0.0.1).
|
68
69
|
--redis-host HOST Redis host to connect to (default: 127.0.0.1).
|
69
70
|
--redis-url URL Redis URL to connect to (e.g.: redis://127.0.0.1:6379/0).
|
@@ -73,10 +74,37 @@ OPTIONS:
|
|
73
74
|
Exits with a non-zero status code if there were any failures.
|
74
75
|
--report-timeout N Fail if build is not finished after N seconds. Only applicable if --report is enabled (default: 3600).
|
75
76
|
--max-requeues N Retry failed examples up to N times before considering them legit failures (default: 3).
|
77
|
+
--queue-wait-timeout N Time to wait for a queue to be ready before considering it failed (default: 30).
|
78
|
+
--fail-fast N Abort build with a non-zero status code after N failed examples.
|
79
|
+
--reproduction Enable reproduction mode: Publish files and examples in the exact order given in the command. Incompatible with --timings.
|
76
80
|
-h, --help Show this message.
|
77
81
|
-v, --version Print the version and exit.
|
78
82
|
```
|
79
83
|
|
84
|
+
You can set most options using ENV variables:
|
85
|
+
|
86
|
+
```shell
|
87
|
+
$ RSPECQ_BUILD=123 RSPECQ_WORKDER=foo1 rspecq spec/
|
88
|
+
```
|
89
|
+
|
90
|
+
### Supported ENV variables
|
91
|
+
|
92
|
+
| Name | Desc |
|
93
|
+
| --- | --- |
|
94
|
+
| `RSPECQ_BUILD` | Build ID |
|
95
|
+
| `RSPECQ_WORKER` | Worker ID |
|
96
|
+
| `RSPECQ_SEED` | RSpec seed |
|
97
|
+
| `RSPECQ_REDIS` | Redis HOST |
|
98
|
+
| `RSPECQ_UPDATE_TIMINGS` | Timings |
|
99
|
+
| `RSPECQ_FILE_SPLIT_THRESHOLD` | File split threshold |
|
100
|
+
| `RSPECQ_REPORT` | Report |
|
101
|
+
| `RSPECQ_REPORT_TIMEOUT` | Report Timeout |
|
102
|
+
| `RSPECQ_MAX_REQUEUES` | Max requests |
|
103
|
+
| `RSPECQ_QUEUE_WAIT_TIMEOUT` | Queue wait timeout |
|
104
|
+
| `RSPECQ_REDIS_URL` | Redis URL |
|
105
|
+
| `RSPECQ_FAIL_FAST` | Fail fast |
|
106
|
+
| `RSPECQ_REPORTER_RERUN_COMMAND_SKIP` | Do not report flaky test's rerun command |
|
107
|
+
|
80
108
|
### Sentry integration
|
81
109
|
|
82
110
|
RSpecQ can optionally emit build events to a
|
@@ -88,7 +116,6 @@ This is convenient for monitoring important warnings/errors that may impact
|
|
88
116
|
build times, such as the fact that no previous timings were found and
|
89
117
|
therefore job scheduling was effectively random for a particular build.
|
90
118
|
|
91
|
-
|
92
119
|
## How it works
|
93
120
|
|
94
121
|
The core design is almost identical to ci-queue so please refer to its
|
@@ -115,7 +142,7 @@ For example, a single file may need 10 minutes to run while all other
|
|
115
142
|
files finish after 8 minutes. This would cause all but one workers to be
|
116
143
|
sitting idle for 2 minutes.
|
117
144
|
|
118
|
-
To overcome this issue, RSpecQ can
|
145
|
+
To overcome this issue, RSpecQ can split files which their execution time is
|
119
146
|
above a certain threshold (set with the `--file-split-threshold` option)
|
120
147
|
and instead schedule them as individual examples.
|
121
148
|
|
@@ -133,6 +160,16 @@ final report.
|
|
133
160
|
Flaky tests are also detected and printed as such in the final report. They are
|
134
161
|
also emitted to Sentry (see [Sentry integration](#sentry-integration)).
|
135
162
|
|
163
|
+
### Fail-fast
|
164
|
+
|
165
|
+
In order to prevent large suites running for a long time with a lot of
|
166
|
+
failures, a threshold can be set to control the number of failed examples that
|
167
|
+
will render the build unsuccessful. This is in par with RSpec's
|
168
|
+
[--fail-fast](https://relishapp.com/rspec/rspec-core/docs/command-line/fail-fast-option).
|
169
|
+
|
170
|
+
This feature is disabled by default, and can be controlled via the
|
171
|
+
`--fail-fast` command line option.
|
172
|
+
|
136
173
|
### Worker failures
|
137
174
|
|
138
175
|
It's not uncommon for CI processes to encounter unrecoverable failures for
|
@@ -204,6 +241,13 @@ To enable verbose output in the tests:
|
|
204
241
|
$ RSPECQ_DEBUG=1 bundle exec rake
|
205
242
|
```
|
206
243
|
|
244
|
+
## Redis
|
245
|
+
|
246
|
+
RSpecQ by design doesn't expire its keys from Redis. It is left to the user
|
247
|
+
to configure the Redis server to do so; see
|
248
|
+
[Using Redis as an LRU cache](https://redis.io/topics/lru-cache) for more info.
|
249
|
+
|
250
|
+
You can do this from a configuration file or with `redis-cli`.
|
207
251
|
|
208
252
|
## License
|
209
253
|
|
data/Rakefile
CHANGED
data/bin/rspecq
CHANGED
@@ -2,9 +2,11 @@
|
|
2
2
|
require "optparse"
|
3
3
|
require "rspecq"
|
4
4
|
|
5
|
-
DEFAULT_REDIS_HOST = "127.0.0.1"
|
5
|
+
DEFAULT_REDIS_HOST = "127.0.0.1".freeze
|
6
6
|
DEFAULT_REPORT_TIMEOUT = 3600 # 1 hour
|
7
7
|
DEFAULT_MAX_REQUEUES = 3
|
8
|
+
DEFAULT_QUEUE_WAIT_TIMEOUT = 30
|
9
|
+
DEFAULT_FAIL_FAST = 0
|
8
10
|
|
9
11
|
def env_set?(var)
|
10
12
|
["1", "true"].include?(ENV[var])
|
@@ -36,9 +38,14 @@ OptionParser.new do |o|
|
|
36
38
|
opts[:worker] = v
|
37
39
|
end
|
38
40
|
|
41
|
+
o.on("--seed SEED", "The RSpec seed. Passing the seed can be helpful in " \
|
42
|
+
"many ways i.e reproduction and testing.") do |v|
|
43
|
+
opts[:seed] = v
|
44
|
+
end
|
45
|
+
|
39
46
|
o.on("-r", "--redis HOST", "Redis host to connect to " \
|
40
47
|
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
41
|
-
puts "--redis is deprecated. Use --redis-host or --redis-url instead"
|
48
|
+
puts "DEPRECATION: --redis is deprecated. Use --redis-host or --redis-url instead"
|
42
49
|
opts[:redis_host] = v
|
43
50
|
end
|
44
51
|
|
@@ -65,7 +72,7 @@ OptionParser.new do |o|
|
|
65
72
|
|
66
73
|
o.on("--report", "Enable reporter mode: do not pull tests off the queue; " \
|
67
74
|
"instead print build progress and exit when it's " \
|
68
|
-
"finished.\n#{o.summary_indent*9} " \
|
75
|
+
"finished.\n#{o.summary_indent * 9} " \
|
69
76
|
"Exits with a non-zero status code if there were any " \
|
70
77
|
"failures.") do |v|
|
71
78
|
opts[:report] = v
|
@@ -83,6 +90,23 @@ OptionParser.new do |o|
|
|
83
90
|
opts[:max_requeues] = v
|
84
91
|
end
|
85
92
|
|
93
|
+
o.on("--queue-wait-timeout N", Integer, "Time to wait for a queue to be " \
|
94
|
+
"ready before considering it failed " \
|
95
|
+
"(default: #{DEFAULT_QUEUE_WAIT_TIMEOUT}).") do |v|
|
96
|
+
opts[:queue_wait_timeout] = v
|
97
|
+
end
|
98
|
+
|
99
|
+
o.on("--fail-fast N", Integer, "Abort build with a non-zero status code " \
|
100
|
+
"after N failed examples.") do |v|
|
101
|
+
opts[:fail_fast] = v
|
102
|
+
end
|
103
|
+
|
104
|
+
o.on("--reproduction", "Enable reproduction mode: run rspec on the given files " \
|
105
|
+
"and examples in the exact order they are given. Incompatible with " \
|
106
|
+
"--timings.") do |v|
|
107
|
+
opts[:reproduction] = v
|
108
|
+
end
|
109
|
+
|
86
110
|
o.on_tail("-h", "--help", "Show this message.") do
|
87
111
|
puts o
|
88
112
|
exit
|
@@ -96,16 +120,22 @@ end.parse!
|
|
96
120
|
|
97
121
|
opts[:build] ||= ENV["RSPECQ_BUILD"]
|
98
122
|
opts[:worker] ||= ENV["RSPECQ_WORKER"]
|
123
|
+
opts[:seed] ||= ENV["RSPECQ_SEED"]
|
99
124
|
opts[:redis_host] ||= ENV["RSPECQ_REDIS"] || DEFAULT_REDIS_HOST
|
100
125
|
opts[:timings] ||= env_set?("RSPECQ_UPDATE_TIMINGS")
|
101
|
-
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] ||
|
126
|
+
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] || 9_999_999)
|
102
127
|
opts[:report] ||= env_set?("RSPECQ_REPORT")
|
103
128
|
opts[:report_timeout] ||= Integer(ENV["RSPECQ_REPORT_TIMEOUT"] || DEFAULT_REPORT_TIMEOUT)
|
104
129
|
opts[:max_requeues] ||= Integer(ENV["RSPECQ_MAX_REQUEUES"] || DEFAULT_MAX_REQUEUES)
|
130
|
+
opts[:queue_wait_timeout] ||= Integer(ENV["RSPECQ_QUEUE_WAIT_TIMEOUT"] || DEFAULT_QUEUE_WAIT_TIMEOUT)
|
105
131
|
opts[:redis_url] ||= ENV["RSPECQ_REDIS_URL"]
|
132
|
+
opts[:fail_fast] ||= Integer(ENV["RSPECQ_FAIL_FAST"] || DEFAULT_FAIL_FAST)
|
133
|
+
opts[:reproduction] ||= env_set?("RSPECQ_REPRODUCTION")
|
106
134
|
|
135
|
+
# rubocop:disable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
107
136
|
raise OptionParser::MissingArgument.new(:build) if opts[:build].nil?
|
108
137
|
raise OptionParser::MissingArgument.new(:worker) if !opts[:report] && opts[:worker].nil?
|
138
|
+
# rubocop:enable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
109
139
|
|
110
140
|
redis_opts = {}
|
111
141
|
|
@@ -120,6 +150,7 @@ if opts[:report]
|
|
120
150
|
build_id: opts[:build],
|
121
151
|
timeout: opts[:report_timeout],
|
122
152
|
redis_opts: redis_opts,
|
153
|
+
queue_wait_timeout: opts[:queue_wait_timeout]
|
123
154
|
)
|
124
155
|
|
125
156
|
reporter.report
|
@@ -130,9 +161,13 @@ else
|
|
130
161
|
redis_opts: redis_opts
|
131
162
|
)
|
132
163
|
|
133
|
-
worker.files_or_dirs_to_run = ARGV
|
164
|
+
worker.files_or_dirs_to_run = ARGV if ARGV.any?
|
134
165
|
worker.populate_timings = opts[:timings]
|
135
166
|
worker.file_split_threshold = opts[:file_split_threshold]
|
136
167
|
worker.max_requeues = opts[:max_requeues]
|
168
|
+
worker.queue_wait_timeout = opts[:queue_wait_timeout]
|
169
|
+
worker.fail_fast = opts[:fail_fast]
|
170
|
+
worker.seed = Integer(opts[:seed]) if opts[:seed]
|
171
|
+
worker.reproduction = opts[:reproduction]
|
137
172
|
worker.work
|
138
173
|
end
|
@@ -1,12 +1,18 @@
|
|
1
1
|
module RSpecQ
|
2
2
|
module Formatters
|
3
|
+
# Persists failed examples information (i.e. message and backtrace), so
|
4
|
+
# that they can be reported to the end user by the Reporter.
|
5
|
+
#
|
6
|
+
# Also persists non-example error information (e.g. a syntax error that
|
7
|
+
# in a spec file).
|
3
8
|
class FailureRecorder
|
4
|
-
def initialize(queue, job, max_requeues)
|
9
|
+
def initialize(queue, job, max_requeues, worker_id)
|
5
10
|
@queue = queue
|
6
11
|
@job = job
|
7
12
|
@colorizer = RSpec::Core::Formatters::ConsoleCodes
|
8
13
|
@non_example_error_recorded = false
|
9
14
|
@max_requeues = max_requeues
|
15
|
+
@worker_id = worker_id
|
10
16
|
end
|
11
17
|
|
12
18
|
# Here we're notified about errors occuring outside of examples.
|
@@ -25,7 +31,7 @@ module RSpecQ
|
|
25
31
|
def example_failed(notification)
|
26
32
|
example = notification.example
|
27
33
|
|
28
|
-
if @queue.requeue_job(example
|
34
|
+
if @queue.requeue_job(example, @max_requeues, @worker_id)
|
29
35
|
# HACK: try to avoid picking the job we just requeued; we want it
|
30
36
|
# to be picked up by a different worker
|
31
37
|
sleep 0.5
|
@@ -33,16 +39,19 @@ module RSpecQ
|
|
33
39
|
end
|
34
40
|
|
35
41
|
presenter = RSpec::Core::Formatters::ExceptionPresenter.new(
|
36
|
-
example.exception, example
|
42
|
+
example.exception, example
|
43
|
+
)
|
37
44
|
|
38
45
|
msg = presenter.fully_formatted(nil, @colorizer)
|
39
46
|
msg << "\n"
|
40
47
|
msg << @colorizer.wrap(
|
41
|
-
"bin/rspec #{example.location_rerun_argument}",
|
42
|
-
RSpec.configuration.failure_color
|
48
|
+
"bin/rspec --seed #{RSpec.configuration.seed} #{example.location_rerun_argument}",
|
49
|
+
RSpec.configuration.failure_color
|
50
|
+
)
|
43
51
|
|
44
52
|
msg << @colorizer.wrap(
|
45
|
-
" # #{example.full_description}", RSpec.configuration.detail_color
|
53
|
+
" # #{example.full_description}", RSpec.configuration.detail_color
|
54
|
+
)
|
46
55
|
|
47
56
|
@queue.record_example_failure(notification.example.id, msg)
|
48
57
|
end
|
@@ -1,5 +1,8 @@
|
|
1
1
|
module RSpecQ
|
2
2
|
module Formatters
|
3
|
+
# Persists each job's timing (in seconds). Those timings are used when
|
4
|
+
# determining the ordering in which jobs are scheduled (slower jobs will
|
5
|
+
# be enqueued first).
|
3
6
|
class JobTimingRecorder
|
4
7
|
def initialize(queue, job)
|
5
8
|
@queue = queue
|
data/lib/rspecq/queue.rb
CHANGED
@@ -51,8 +51,12 @@ module RSpecQ
|
|
51
51
|
REQUEUE_JOB = <<~LUA.freeze
|
52
52
|
local key_queue_unprocessed = KEYS[1]
|
53
53
|
local key_requeues = KEYS[2]
|
54
|
+
local key_requeued_job_original_worker = KEYS[3]
|
55
|
+
local key_job_location = KEYS[4]
|
54
56
|
local job = ARGV[1]
|
55
57
|
local max_requeues = ARGV[2]
|
58
|
+
local original_worker = ARGV[3]
|
59
|
+
local location = ARGV[4]
|
56
60
|
|
57
61
|
local requeued_times = redis.call('hget', key_requeues, job)
|
58
62
|
if requeued_times and requeued_times >= max_requeues then
|
@@ -60,7 +64,9 @@ module RSpecQ
|
|
60
64
|
end
|
61
65
|
|
62
66
|
redis.call('lpush', key_queue_unprocessed, job)
|
67
|
+
redis.call('hset', key_requeued_job_original_worker, job, original_worker)
|
63
68
|
redis.call('hincrby', key_requeues, job, 1)
|
69
|
+
redis.call('hset', key_job_location, job, location)
|
64
70
|
|
65
71
|
return true
|
66
72
|
LUA
|
@@ -77,8 +83,9 @@ module RSpecQ
|
|
77
83
|
end
|
78
84
|
|
79
85
|
# NOTE: jobs will be processed from head to tail (lpop)
|
80
|
-
def publish(jobs)
|
86
|
+
def publish(jobs, fail_fast = 0)
|
81
87
|
@redis.multi do
|
88
|
+
@redis.hset(key_queue_config, "fail_fast", fail_fast)
|
82
89
|
@redis.rpush(key_queue_unprocessed, jobs)
|
83
90
|
@redis.set(key_queue_status, STATUS_READY)
|
84
91
|
end.first
|
@@ -116,6 +123,7 @@ module RSpecQ
|
|
116
123
|
@redis.multi do
|
117
124
|
@redis.hdel(key_queue_running, @worker_id)
|
118
125
|
@redis.sadd(key_queue_processed, job)
|
126
|
+
@redis.rpush(key("queue", "jobs_per_worker", @worker_id), job)
|
119
127
|
end
|
120
128
|
end
|
121
129
|
|
@@ -124,16 +132,41 @@ module RSpecQ
|
|
124
132
|
#
|
125
133
|
# Returns nil if the job hit the requeue limit and therefore was not
|
126
134
|
# requeued and should be considered a failure.
|
127
|
-
def requeue_job(
|
135
|
+
def requeue_job(example, max_requeues, original_worker_id)
|
128
136
|
return false if max_requeues.zero?
|
129
137
|
|
138
|
+
job = example.id
|
139
|
+
location = example.location_rerun_argument
|
140
|
+
|
130
141
|
@redis.eval(
|
131
142
|
REQUEUE_JOB,
|
132
|
-
keys: [key_queue_unprocessed, key_requeues],
|
133
|
-
argv: [job, max_requeues]
|
143
|
+
keys: [key_queue_unprocessed, key_requeues, key("requeued_job_original_worker"), key("job_location")],
|
144
|
+
argv: [job, max_requeues, original_worker_id, location]
|
134
145
|
)
|
135
146
|
end
|
136
147
|
|
148
|
+
def save_worker_seed(worker, seed)
|
149
|
+
@redis.hset(key("worker_seed"), worker, seed)
|
150
|
+
end
|
151
|
+
|
152
|
+
def job_location(job)
|
153
|
+
@redis.hget(key("job_location"), job)
|
154
|
+
end
|
155
|
+
|
156
|
+
def failed_job_worker(job)
|
157
|
+
redis.hget(key("requeued_job_original_worker"), job)
|
158
|
+
end
|
159
|
+
|
160
|
+
def job_rerun_command(job)
|
161
|
+
worker = failed_job_worker(job)
|
162
|
+
jobs = redis.lrange(key("queue", "jobs_per_worker", worker), 0, -1)
|
163
|
+
seed = redis.hget(key("worker_seed"), worker)
|
164
|
+
|
165
|
+
"DISABLE_SPRING=1 DISABLE_BOOTSNAP=1 bin/rspecq --build 1 " \
|
166
|
+
"--worker foo --seed #{seed} --max-requeues 0 --fail-fast 1 " \
|
167
|
+
"--reproduction #{jobs.join(' ')}"
|
168
|
+
end
|
169
|
+
|
137
170
|
def record_example_failure(example_id, message)
|
138
171
|
@redis.hset(key_failures, example_id, message)
|
139
172
|
end
|
@@ -209,9 +242,10 @@ module RSpecQ
|
|
209
242
|
@redis.get(key_queue_status) == STATUS_READY
|
210
243
|
end
|
211
244
|
|
212
|
-
def wait_until_published(timeout=30)
|
245
|
+
def wait_until_published(timeout = 30)
|
213
246
|
(timeout * 10).times do
|
214
247
|
return if published?
|
248
|
+
|
215
249
|
sleep 0.1
|
216
250
|
end
|
217
251
|
|
@@ -232,7 +266,9 @@ module RSpecQ
|
|
232
266
|
# after being retried). Must be called after the build is complete,
|
233
267
|
# otherwise an exception will be raised.
|
234
268
|
def flaky_jobs
|
235
|
-
|
269
|
+
if !exhausted? && !build_failed_fast?
|
270
|
+
raise "Queue is not yet exhausted"
|
271
|
+
end
|
236
272
|
|
237
273
|
requeued = @redis.hkeys(key_requeues)
|
238
274
|
|
@@ -241,11 +277,38 @@ module RSpecQ
|
|
241
277
|
requeued - @redis.hkeys(key_failures)
|
242
278
|
end
|
243
279
|
|
280
|
+
# Returns the number of failures that will trigger the build to fail-fast.
|
281
|
+
# Returns 0 if this feature is disabled and nil if the Queue is not yet
|
282
|
+
# published
|
283
|
+
def fail_fast
|
284
|
+
return nil unless published?
|
285
|
+
|
286
|
+
@fail_fast ||= Integer(@redis.hget(key_queue_config, "fail_fast"))
|
287
|
+
end
|
288
|
+
|
289
|
+
# Returns true if the number of failed tests, has surpassed the threshold
|
290
|
+
# to render the run unsuccessful and the build should be terminated.
|
291
|
+
def build_failed_fast?
|
292
|
+
if fail_fast.nil? || fail_fast.zero?
|
293
|
+
return false
|
294
|
+
end
|
295
|
+
|
296
|
+
@redis.multi do
|
297
|
+
@redis.hlen(key_failures)
|
298
|
+
@redis.hlen(key_errors)
|
299
|
+
end.inject(:+) >= fail_fast
|
300
|
+
end
|
301
|
+
|
244
302
|
# redis: STRING [STATUS_INITIALIZING, STATUS_READY]
|
245
303
|
def key_queue_status
|
246
304
|
key("queue", "status")
|
247
305
|
end
|
248
306
|
|
307
|
+
# redis: HASH<config_key => config_value>
|
308
|
+
def key_queue_config
|
309
|
+
key("queue", "config")
|
310
|
+
end
|
311
|
+
|
249
312
|
# redis: LIST<job>
|
250
313
|
def key_queue_unprocessed
|
251
314
|
key("queue", "unprocessed")
|
data/lib/rspecq/reporter.rb
CHANGED
@@ -9,18 +9,19 @@ module RSpecQ
|
|
9
9
|
#
|
10
10
|
# Reporters are readers of the queue.
|
11
11
|
class Reporter
|
12
|
-
def initialize(build_id:, timeout:, redis_opts:)
|
12
|
+
def initialize(build_id:, timeout:, redis_opts:, queue_wait_timeout: 30)
|
13
13
|
@build_id = build_id
|
14
14
|
@timeout = timeout
|
15
15
|
@queue = Queue.new(build_id, "reporter", redis_opts)
|
16
|
+
@queue_wait_timeout = queue_wait_timeout
|
16
17
|
|
17
18
|
# We want feedback to be immediattely printed to CI users, so
|
18
19
|
# we disable buffering.
|
19
|
-
|
20
|
+
$stdout.sync = true
|
20
21
|
end
|
21
22
|
|
22
23
|
def report
|
23
|
-
@queue.wait_until_published
|
24
|
+
@queue.wait_until_published(@queue_wait_timeout)
|
24
25
|
|
25
26
|
finished = false
|
26
27
|
|
@@ -28,7 +29,7 @@ module RSpecQ
|
|
28
29
|
failure_heading_printed = false
|
29
30
|
|
30
31
|
tests_duration = measure_duration do
|
31
|
-
@timeout.times do
|
32
|
+
@timeout.times do
|
32
33
|
@queue.example_failures.each do |job, rspec_output|
|
33
34
|
next if reported_failures[job]
|
34
35
|
|
@@ -41,7 +42,7 @@ module RSpecQ
|
|
41
42
|
puts failure_formatted(rspec_output)
|
42
43
|
end
|
43
44
|
|
44
|
-
|
45
|
+
unless @queue.exhausted? || @queue.build_failed_fast?
|
45
46
|
sleep 1
|
46
47
|
next
|
47
48
|
end
|
@@ -83,6 +84,13 @@ module RSpecQ
|
|
83
84
|
end
|
84
85
|
|
85
86
|
summary = ""
|
87
|
+
if @queue.build_failed_fast?
|
88
|
+
summary << "\n\n"
|
89
|
+
summary << "The limit of #{@queue.fail_fast} failures has been reached\n"
|
90
|
+
summary << "Aborting..."
|
91
|
+
summary << "\n"
|
92
|
+
end
|
93
|
+
|
86
94
|
summary << failed_examples_section if !failures.empty?
|
87
95
|
|
88
96
|
errors.each { |_job, msg| summary << msg }
|
@@ -99,7 +107,16 @@ module RSpecQ
|
|
99
107
|
if !flaky_jobs.empty?
|
100
108
|
summary << "\n\n"
|
101
109
|
summary << "Flaky jobs detected (count=#{flaky_jobs.count}):\n"
|
102
|
-
flaky_jobs.each
|
110
|
+
flaky_jobs.each do |j|
|
111
|
+
summary << RSpec::Core::Formatters::ConsoleCodes.wrap(
|
112
|
+
"#{@queue.job_location(j)} @ #{@queue.failed_job_worker(j)}\n",
|
113
|
+
RSpec.configuration.pending_color
|
114
|
+
)
|
115
|
+
|
116
|
+
next if ENV["RSPECQ_REPORTER_RERUN_COMMAND_SKIP"]
|
117
|
+
|
118
|
+
summary << "#{@queue.job_rerun_command(j)}\n\n\n"
|
119
|
+
end
|
103
120
|
end
|
104
121
|
|
105
122
|
summary
|
@@ -117,16 +134,15 @@ module RSpecQ
|
|
117
134
|
return if jobs.empty?
|
118
135
|
|
119
136
|
jobs.each do |job|
|
120
|
-
filename = job.sub(/\[.+\]/,
|
137
|
+
filename = job.sub(/\[.+\]/, "")[%r{spec/.+}].split(":")[0]
|
121
138
|
|
122
139
|
extra = {
|
123
140
|
build: @build_id,
|
124
141
|
build_timeout: @timeout,
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
build_duration: build_duration
|
142
|
+
build_duration: build_duration,
|
143
|
+
location: @queue.job_location(job),
|
144
|
+
rerun_command: @queue.job_rerun_command(job),
|
145
|
+
worker: @queue.failed_job_worker(job)
|
130
146
|
}
|
131
147
|
|
132
148
|
tags = {
|
@@ -136,7 +152,7 @@ module RSpecQ
|
|
136
152
|
|
137
153
|
Raven.capture_message(
|
138
154
|
"Flaky test in #{filename}",
|
139
|
-
level:
|
155
|
+
level: "warning",
|
140
156
|
extra: extra,
|
141
157
|
tags: tags
|
142
158
|
)
|
data/lib/rspecq/version.rb
CHANGED
data/lib/rspecq/worker.rb
CHANGED
@@ -40,17 +40,39 @@ module RSpecQ
|
|
40
40
|
# Defaults to 3
|
41
41
|
attr_accessor :max_requeues
|
42
42
|
|
43
|
+
# Stop the execution after N failed tests. Do not stop at any point when
|
44
|
+
# set to 0.
|
45
|
+
#
|
46
|
+
# Defaults to 0
|
47
|
+
attr_accessor :fail_fast
|
48
|
+
|
49
|
+
# Time to wait for a queue to be published.
|
50
|
+
#
|
51
|
+
# Defaults to 30
|
52
|
+
attr_accessor :queue_wait_timeout
|
53
|
+
|
54
|
+
# The RSpec seed
|
55
|
+
attr_accessor :seed
|
56
|
+
|
57
|
+
# Reproduction flag. If true, worker will publish files in the exact order
|
58
|
+
# given in the command.
|
59
|
+
attr_accessor :reproduction
|
60
|
+
|
43
61
|
attr_reader :queue
|
44
62
|
|
45
63
|
def initialize(build_id:, worker_id:, redis_opts:)
|
46
64
|
@build_id = build_id
|
47
65
|
@worker_id = worker_id
|
48
66
|
@queue = Queue.new(build_id, worker_id, redis_opts)
|
67
|
+
@fail_fast = 0
|
49
68
|
@files_or_dirs_to_run = "spec"
|
50
69
|
@populate_timings = false
|
51
|
-
@file_split_threshold =
|
70
|
+
@file_split_threshold = 999_999
|
52
71
|
@heartbeat_updated_at = nil
|
53
72
|
@max_requeues = 3
|
73
|
+
@queue_wait_timeout = 30
|
74
|
+
@seed = srand && srand % 0xFFFF
|
75
|
+
@reproduction = false
|
54
76
|
|
55
77
|
RSpec::Core::Formatters.register(Formatters::JobTimingRecorder, :dump_summary)
|
56
78
|
RSpec::Core::Formatters.register(Formatters::ExampleCountRecorder, :dump_summary)
|
@@ -62,13 +84,16 @@ module RSpecQ
|
|
62
84
|
puts "Working for build #{@build_id} (worker=#{@worker_id})"
|
63
85
|
|
64
86
|
try_publish_queue!(queue)
|
65
|
-
queue.wait_until_published
|
87
|
+
queue.wait_until_published(queue_wait_timeout)
|
88
|
+
queue.save_worker_seed(@worker_id, seed)
|
66
89
|
|
67
90
|
loop do
|
68
91
|
# we have to bootstrap this so that it can be used in the first call
|
69
92
|
# to `requeue_lost_job` inside the work loop
|
70
93
|
update_heartbeat
|
71
94
|
|
95
|
+
return if queue.build_failed_fast?
|
96
|
+
|
72
97
|
lost = queue.requeue_lost_job
|
73
98
|
puts "Requeued lost job: #{lost}" if lost
|
74
99
|
|
@@ -88,9 +113,9 @@ module RSpecQ
|
|
88
113
|
|
89
114
|
# reconfigure rspec
|
90
115
|
RSpec.configuration.detail_color = :magenta
|
91
|
-
RSpec.configuration.seed =
|
92
|
-
RSpec.configuration.backtrace_formatter.filter_gem(
|
93
|
-
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues))
|
116
|
+
RSpec.configuration.seed = seed
|
117
|
+
RSpec.configuration.backtrace_formatter.filter_gem("rspecq")
|
118
|
+
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues, @worker_id))
|
94
119
|
RSpec.configuration.add_formatter(Formatters::ExampleCountRecorder.new(queue))
|
95
120
|
RSpec.configuration.add_formatter(Formatters::WorkerHeartbeatRecorder.new(self))
|
96
121
|
|
@@ -116,12 +141,21 @@ module RSpecQ
|
|
116
141
|
def try_publish_queue!(queue)
|
117
142
|
return if !queue.become_master
|
118
143
|
|
144
|
+
if reproduction
|
145
|
+
q_size = queue.publish(files_or_dirs_to_run, fail_fast)
|
146
|
+
log_event(
|
147
|
+
"Reproduction mode. Published queue as given (size=#{q_size})",
|
148
|
+
"info"
|
149
|
+
)
|
150
|
+
return
|
151
|
+
end
|
152
|
+
|
119
153
|
RSpec.configuration.files_or_directories_to_run = files_or_dirs_to_run
|
120
154
|
files_to_run = RSpec.configuration.files_to_run.map { |j| relative_path(j) }
|
121
155
|
|
122
156
|
timings = queue.timings
|
123
157
|
if timings.empty?
|
124
|
-
q_size = queue.publish(files_to_run.shuffle)
|
158
|
+
q_size = queue.publish(files_to_run.shuffle, fail_fast)
|
125
159
|
log_event(
|
126
160
|
"No timings found! Published queue in random order (size=#{q_size})",
|
127
161
|
"warning"
|
@@ -146,7 +180,7 @@ module RSpecQ
|
|
146
180
|
jobs.concat(files_to_run)
|
147
181
|
end
|
148
182
|
|
149
|
-
default_timing = timings.values[timings.values.size/2]
|
183
|
+
default_timing = timings.values[timings.values.size / 2]
|
150
184
|
|
151
185
|
# assign timings (based on previous runs) to all jobs
|
152
186
|
jobs = jobs.each_with_object({}) do |j, h|
|
@@ -160,7 +194,7 @@ module RSpecQ
|
|
160
194
|
# sort jobs based on their timings (slowest to be processed first)
|
161
195
|
jobs = jobs.sort_by { |_j, t| -t }.map(&:first)
|
162
196
|
|
163
|
-
puts "Published queue (size=#{queue.publish(jobs)})"
|
197
|
+
puts "Published queue (size=#{queue.publish(jobs, fail_fast)})"
|
164
198
|
end
|
165
199
|
|
166
200
|
private
|
@@ -171,7 +205,8 @@ module RSpecQ
|
|
171
205
|
# see https://github.com/rspec/rspec-core/pull/2723
|
172
206
|
if Gem::Version.new(RSpec::Core::Version::STRING) <= Gem::Version.new("3.9.1")
|
173
207
|
RSpec.world.instance_variable_set(
|
174
|
-
:@example_group_counts_by_spec_file, Hash.new(0)
|
208
|
+
:@example_group_counts_by_spec_file, Hash.new(0)
|
209
|
+
)
|
175
210
|
end
|
176
211
|
|
177
212
|
# RSpec.clear_examples does not reset those, which causes issues when
|
@@ -195,17 +230,17 @@ module RSpecQ
|
|
195
230
|
|
196
231
|
if !cmd_result.success?
|
197
232
|
rspec_output = begin
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
233
|
+
JSON.parse(out)
|
234
|
+
rescue JSON::ParserError
|
235
|
+
out
|
236
|
+
end
|
202
237
|
|
203
238
|
log_event(
|
204
239
|
"Failed to split slow files, falling back to regular scheduling.\n #{err}",
|
205
240
|
"error",
|
206
241
|
rspec_stdout: rspec_output,
|
207
242
|
rspec_stderr: err,
|
208
|
-
cmd_result: cmd_result.inspect
|
243
|
+
cmd_result: cmd_result.inspect
|
209
244
|
)
|
210
245
|
|
211
246
|
pp rspec_output
|
@@ -227,7 +262,7 @@ module RSpecQ
|
|
227
262
|
|
228
263
|
# Prints msg to standard output and emits an event to Sentry, if the
|
229
264
|
# SENTRY_DSN environment variable is set.
|
230
|
-
def log_event(msg, level, additional={})
|
265
|
+
def log_event(msg, level, additional = {})
|
231
266
|
puts msg
|
232
267
|
|
233
268
|
Raven.capture_message(msg, level: level, extra: {
|
@@ -238,8 +273,8 @@ module RSpecQ
|
|
238
273
|
populate_timings: populate_timings,
|
239
274
|
file_split_threshold: file_split_threshold,
|
240
275
|
heartbeat_updated_at: @heartbeat_updated_at,
|
241
|
-
object:
|
242
|
-
pid: Process.pid
|
276
|
+
object: inspect,
|
277
|
+
pid: Process.pid
|
243
278
|
}.merge(additional))
|
244
279
|
end
|
245
280
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rspecq
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Agis Anastasopoulos
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-04-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec-core
|
@@ -53,7 +53,7 @@ dependencies:
|
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
56
|
+
name: minitest
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
@@ -81,7 +81,7 @@ dependencies:
|
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
84
|
+
name: rake
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
87
|
- - ">="
|
@@ -108,6 +108,20 @@ dependencies:
|
|
108
108
|
- - ">="
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 0.93.0
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 0.93.0
|
111
125
|
description:
|
112
126
|
email: agis.anast@gmail.com
|
113
127
|
executables:
|
@@ -149,7 +163,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
163
|
- !ruby/object:Gem::Version
|
150
164
|
version: '0'
|
151
165
|
requirements: []
|
152
|
-
|
166
|
+
rubyforge_project:
|
167
|
+
rubygems_version: 2.7.6.2
|
153
168
|
signing_key:
|
154
169
|
specification_version: 4
|
155
170
|
summary: Optimally distribute and run RSpec suites among parallel workers; for faster
|