rspecq 0.0.1.pre2 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +170 -63
- data/Rakefile +9 -0
- data/bin/rspecq +99 -28
- data/lib/rspecq.rb +5 -7
- data/lib/rspecq/formatters/README.md +4 -0
- data/lib/rspecq/formatters/failure_recorder.rb +3 -2
- data/lib/rspecq/queue.rb +47 -6
- data/lib/rspecq/reporter.rb +57 -6
- data/lib/rspecq/version.rb +1 -1
- data/lib/rspecq/worker.rb +128 -67
- metadata +56 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 89dbfa98d1eaceb06c39d41ab85e7fa6923d0c87e9a15b9cbfaf7399ff2aaff3
|
4
|
+
data.tar.gz: b7cd028440e6eb03401dc623c7ee0fc0fe74f6ffa12a25ecc23d0cf54e6acd1e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a43f0630e8a02a001132f45c9f68cacf7edae8e90487112e640eb611e7d1345f68ad0ab163ae01c91cb38ebc879e98cda9b54044c0ee676293c7aa3bf7c17942
|
7
|
+
data.tar.gz: bf98027dc02ac56d02cc258700f5efa766c40d4d69c55c529d311083965d1fe4f76423b05fddb35a37496bb6eb3c11a8460a8e76f94897c4bb38a744b2fb40df
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,58 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
Breaking changes are prefixed with a "[BREAKING]" label.
|
4
|
+
|
3
5
|
## master (unreleased)
|
4
6
|
|
7
|
+
## 0.3.0 (2020-10-05)
|
8
|
+
|
9
|
+
### Added
|
10
|
+
|
11
|
+
- Providing a Redis URL is now possible using the `--redis-url` option
|
12
|
+
[[#40](https://github.com/skroutz/rspecq/pull/40)]
|
13
|
+
|
14
|
+
### Changed
|
15
|
+
|
16
|
+
- [DEPRECATION] The `--redis` option is now deprecated. Use `--redis-host`
|
17
|
+
instead [[#40](https://github.com/skroutz/rspecq/pull/40)]
|
18
|
+
|
19
|
+
## 0.2.2 (2020-09-10)
|
20
|
+
|
21
|
+
### Fixed
|
22
|
+
- Worker would fail if application code was writing to stderr
|
23
|
+
[[#35](https://github.com/skroutz/rspecq/pull/35)]
|
24
|
+
|
25
|
+
## 0.2.1 (2020-09-09)
|
26
|
+
|
27
|
+
### Changed
|
28
|
+
|
29
|
+
- Sentry Integration: Changed the way events for flaky jobs are emitted to a
|
30
|
+
per-flaky-job fashion. This ultimately improves grouping and filtering of the
|
31
|
+
flaky events in Sentry [[#33](https://github.com/skroutz/rspecq/pull/33)]
|
32
|
+
|
33
|
+
|
34
|
+
## 0.2.0 (2020-08-31)
|
35
|
+
|
36
|
+
This is a feature release with no breaking changes.
|
37
|
+
|
38
|
+
### Added
|
39
|
+
|
40
|
+
- Flaky jobs are now printed by the reporter in the final build output and also
|
41
|
+
emitted to Sentry (if the integration is enabled) [[#26](https://github.com/skroutz/rspecq/pull/26)]
|
42
|
+
|
43
|
+
## 0.1.0 (2020-08-27)
|
44
|
+
|
45
|
+
### Added
|
46
|
+
|
47
|
+
- Sentry integration for various RSpecQ-level events [[#16](https://github.com/skroutz/rspecq/pull/16)]
|
48
|
+
- CLI: Flags can now be also set environment variables [[c519230](https://github.com/skroutz/rspecq/commit/c5192303e229f361e8ac86ae449b4ea84d42e022)]
|
49
|
+
- CLI: Added shorthand specifiers versions for some flags [[df9faa8](https://github.com/skroutz/rspecq/commit/df9faa8ec6721af8357cfee4de6a2fe7b32070fc)]
|
50
|
+
- CLI: Added `--help` and `--version` flags [[df9faa8](https://github.com/skroutz/rspecq/commit/df9faa8ec6721af8357cfee4de6a2fe7b32070fc)]
|
51
|
+
- CLI: Max number of retries for failed examples is now configurable via the `--max-requeues` option [[#14](https://github.com/skroutz/rspecq/pull/14)]
|
52
|
+
|
53
|
+
### Changed
|
54
|
+
|
55
|
+
- [BREAKING] CLI: Renamed `--timings` to `--update-timings` [[c519230](https://github.com/skroutz/rspecq/commit/c5192303e229f361e8ac86ae449b4ea84d42e022)]
|
56
|
+
- [BREAKING] CLI: Renamed `--build-id` to `--build` and `--worker-id` to `--worker` [[df9faa8](https://github.com/skroutz/rspecq/commit/df9faa8ec6721af8357cfee4de6a2fe7b32070fc)]
|
57
|
+
- CLI: `--worker` is not required when `--reporter` is used [[4323a75](https://github.com/skroutz/rspecq/commit/4323a75ca357274069d02ba9fb51cdebb04e0be4)]
|
58
|
+
- CLI: Improved help output [[df9faa8](https://github.com/skroutz/rspecq/commit/df9faa8ec6721af8357cfee4de6a2fe7b32070fc)]
|
data/README.md
CHANGED
@@ -1,102 +1,209 @@
|
|
1
|
-
|
1
|
+
RSpec Queue
|
2
|
+
=========================================================================
|
3
|
+
[![Build Status](https://travis-ci.com/skroutz/rspecq.svg?branch=master)](https://travis-ci.com/github/skroutz/rspecq)
|
4
|
+
[![Gem Version](https://badge.fury.io/rb/rspecq.svg)](https://badge.fury.io/rb/rspecq)
|
2
5
|
|
3
|
-
|
4
|
-
|
6
|
+
RSpec Queue (RSpecQ) distributes and executes RSpec suites among parallel
|
7
|
+
workers. It uses a centralized queue that workers connect to and pop off
|
8
|
+
tests from. It ensures optimal scheduling of tests based on their run time,
|
9
|
+
facilitating faster CI builds.
|
5
10
|
|
6
|
-
RSpecQ is
|
11
|
+
RSpecQ is inspired by [test-queue](https://github.com/tmm1/test-queue)
|
7
12
|
and [ci-queue](https://github.com/Shopify/ci-queue).
|
8
13
|
|
9
|
-
##
|
14
|
+
## Features
|
15
|
+
|
16
|
+
- Run an RSpec suite among many workers
|
17
|
+
(potentially located in different hosts) in a distributed fashion,
|
18
|
+
facilitating faster CI builds.
|
19
|
+
- Consolidated, real-time reporting of a build's progress.
|
20
|
+
- Optimal scheduling of test execution by using timings statistics from previous runs and
|
21
|
+
automatically scheduling slow spec files as individual examples. See
|
22
|
+
[*Spec file splitting*](#spec-file-splitting).
|
23
|
+
- Automatic retry of test failures before being considered legit, in order to
|
24
|
+
rule out flakiness. Additionally, flaky tests are detected and provided to
|
25
|
+
the user. See [*Requeues*](#requeues).
|
26
|
+
- Handles intermittent worker failures (e.g. network hiccups, faulty hardware etc.)
|
27
|
+
by detecting non-responsive workers and requeing their jobs. See [*Worker failures*](#worker-failures)
|
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
|
+
See [#2](https://github.com/skroutz/rspecq/issues/2).
|
10
31
|
|
11
|
-
|
12
|
-
in the workers (up to 3 minutes), increased memory consumption and too much
|
13
|
-
disk I/O on boot. This is due to the fact that a worker in ci-queue has to
|
14
|
-
load every spec file on boot. This can be problematic for applications with
|
15
|
-
a large number of spec files.
|
16
|
-
|
17
|
-
RSpecQ works with spec files as its unit of work (as opposed to ci-queue which
|
18
|
-
works with individual examples). This means that an RSpecQ worker does not
|
19
|
-
have to load all spec files at once and so it doesn't have the aforementioned
|
20
|
-
problems. It also allows suites to keep using `before(:all)` hooks
|
21
|
-
(which ci-queue explicitly rejects). (Note: RSpecQ also schedules individual
|
22
|
-
examples, but only when this is deemed necessary, see section
|
23
|
-
"Spec file splitting").
|
32
|
+
## Usage
|
24
33
|
|
25
|
-
|
26
|
-
|
34
|
+
A worker needs to be given a name and the build it will participate in.
|
35
|
+
Assuming there's a Redis instance listening at `localhost`, starting a worker
|
36
|
+
is as simple as:
|
27
37
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
on every run (if the `--timings` option was used). Also, RSpecQ has a "slow
|
32
|
-
file threshold" which, currently has to be set manually (but this can be
|
33
|
-
improved).
|
38
|
+
```shell
|
39
|
+
$ rspecq --build=123 --worker=foo1 spec/
|
40
|
+
```
|
34
41
|
|
35
|
-
|
42
|
+
To start more workers for the same build, use distinct worker IDs but the same
|
43
|
+
build ID:
|
36
44
|
|
37
|
-
|
45
|
+
```shell
|
46
|
+
$ rspecq --build=123 --worker=foo2
|
47
|
+
```
|
38
48
|
|
39
|
-
|
40
|
-
Redis is located. To start a worker:
|
49
|
+
To view the progress of the build use `--report`:
|
41
50
|
|
42
51
|
```shell
|
43
|
-
$ rspecq --build
|
52
|
+
$ rspecq --build=123 --report
|
44
53
|
```
|
45
54
|
|
46
|
-
|
55
|
+
For detailed info use `--help`:
|
47
56
|
|
48
|
-
```shell
|
49
|
-
$ rspecq --build-id=foo --worker-id=reporter --redis=redis://localhost --report
|
50
57
|
```
|
58
|
+
NAME:
|
59
|
+
rspecq - Optimally distribute and run RSpec suites among parallel workers
|
60
|
+
|
61
|
+
USAGE:
|
62
|
+
rspecq [<options>] [spec files or directories]
|
63
|
+
|
64
|
+
OPTIONS:
|
65
|
+
-b, --build ID A unique identifier for the build. Should be common among workers participating in the same build.
|
66
|
+
-w, --worker ID An identifier for the worker. Workers participating in the same build should have distinct IDs.
|
67
|
+
-r, --redis HOST --redis is deprecated. Use --redis-host or --redis-url instead. Redis host to connect to (default: 127.0.0.1).
|
68
|
+
--redis-host HOST Redis host to connect to (default: 127.0.0.1).
|
69
|
+
--redis-url URL Redis URL to connect to (e.g.: redis://127.0.0.1:6379/0).
|
70
|
+
--update-timings Update the global job timings key with the timings of this build. Note: This key is used as the basis for job scheduling.
|
71
|
+
--file-split-threshold N Split spec files slower than N seconds and schedule them as individual examples.
|
72
|
+
--report Enable reporter mode: do not pull tests off the queue; instead print build progress and exit when it's finished.
|
73
|
+
Exits with a non-zero status code if there were any failures.
|
74
|
+
--report-timeout N Fail if build is not finished after N seconds. Only applicable if --report is enabled (default: 3600).
|
75
|
+
--max-requeues N Retry failed examples up to N times before considering them legit failures (default: 3).
|
76
|
+
-h, --help Show this message.
|
77
|
+
-v, --version Print the version and exit.
|
78
|
+
```
|
79
|
+
|
80
|
+
### Sentry integration
|
81
|
+
|
82
|
+
RSpecQ can optionally emit build events to a
|
83
|
+
[Sentry](https://sentry.io) project by setting the
|
84
|
+
[`SENTRY_DSN`](https://github.com/getsentry/raven-ruby#raven-only-runs-when-sentry_dsn-is-set)
|
85
|
+
environment variable.
|
51
86
|
|
52
|
-
|
87
|
+
This is convenient for monitoring important warnings/errors that may impact
|
88
|
+
build times, such as the fact that no previous timings were found and
|
89
|
+
therefore job scheduling was effectively random for a particular build.
|
53
90
|
|
54
91
|
|
55
92
|
## How it works
|
56
93
|
|
57
|
-
The
|
94
|
+
The core design is almost identical to ci-queue so please refer to its
|
95
|
+
[README](https://github.com/Shopify/ci-queue/blob/master/README.md) instead.
|
58
96
|
|
59
97
|
### Terminology
|
60
98
|
|
61
|
-
- Job
|
99
|
+
- **Job**: the smallest unit of work, which is usually a spec file
|
62
100
|
(e.g. `./spec/models/foo_spec.rb`) but can also be an individual example
|
63
|
-
(e.g. `./spec/models/foo_spec.rb[1:2:1]`) if the file is too slow
|
64
|
-
- Queue
|
65
|
-
information for RSpecQ to
|
66
|
-
be executed, the failure reports
|
67
|
-
-
|
68
|
-
|
69
|
-
|
70
|
-
|
101
|
+
(e.g. `./spec/models/foo_spec.rb[1:2:1]`) if the file is too slow.
|
102
|
+
- **Queue**: a collection of Redis-backed structures that hold all the necessary
|
103
|
+
information for an RSpecQ build to run. This includes timing statistics,
|
104
|
+
jobs to be executed, the failure reports and more.
|
105
|
+
- **Build**: a particular test suite run. Each build has its own **Queue**.
|
106
|
+
- **Worker**: an `rspecq` process that, given a build id, consumes jobs off the
|
107
|
+
build's queue and executes them using RSpec
|
108
|
+
- **Reporter**: an `rspecq` process that, given a build id, waits for the build's
|
109
|
+
queue to be drained and prints the build summary report
|
71
110
|
|
72
111
|
### Spec file splitting
|
73
112
|
|
74
|
-
|
75
|
-
a
|
76
|
-
|
77
|
-
|
78
|
-
(set with the `--file-split-threshold` option) and will instead schedule them as
|
79
|
-
individual examples.
|
113
|
+
Particularly slow spec files may set a limit to how fast a build can be.
|
114
|
+
For example, a single file may need 10 minutes to run while all other
|
115
|
+
files finish after 8 minutes. This would cause all but one workers to be
|
116
|
+
sitting idle for 2 minutes.
|
80
117
|
|
81
|
-
|
82
|
-
|
118
|
+
To overcome this issue, RSpecQ can splits files which their execution time is
|
119
|
+
above a certain threshold (set with the `--file-split-threshold` option)
|
120
|
+
and instead schedule them as individual examples.
|
121
|
+
|
122
|
+
Note: In the future, we'd like for the slow threshold to be calculated and set
|
123
|
+
dynamically (see #3).
|
83
124
|
|
84
125
|
### Requeues
|
85
126
|
|
86
|
-
As a mitigation
|
87
|
-
back to the queue to be picked up by
|
88
|
-
|
89
|
-
|
90
|
-
|
127
|
+
As a mitigation technique against flaky tests, if an example fails it will be
|
128
|
+
put back to the queue to be picked up by another worker. This will be repeated
|
129
|
+
up to a certain number of times (set with the `--max-requeues` option), after
|
130
|
+
which the example will be considered a legit failure and printed as such in the
|
131
|
+
final report.
|
132
|
+
|
133
|
+
Flaky tests are also detected and printed as such in the final report. They are
|
134
|
+
also emitted to Sentry (see [Sentry integration](#sentry-integration)).
|
91
135
|
|
92
136
|
### Worker failures
|
93
137
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
138
|
+
It's not uncommon for CI processes to encounter unrecoverable failures for
|
139
|
+
various reasons: faulty hardware, network hiccups, segmentation faults in
|
140
|
+
MRI etc.
|
141
|
+
|
142
|
+
For resiliency against such issues, workers emit a heartbeat after each
|
143
|
+
example they execute, to signal
|
144
|
+
that they're healthy and performing jobs as expected. If a worker hasn't
|
145
|
+
emitted a heartbeat for a given amount of time (set by `WORKER_LIVENESS_SEC`)
|
146
|
+
it is considered dead and its reserved job will be put back to the queue, to
|
147
|
+
be picked up by another healthy worker.
|
148
|
+
|
149
|
+
|
150
|
+
## Rationale
|
151
|
+
|
152
|
+
### Why didn't you use ci-queue?
|
153
|
+
|
154
|
+
**Update**: ci-queue [deprecated support for RSpec](https://github.com/Shopify/ci-queue/pull/149).
|
155
|
+
|
156
|
+
While evaluating ci-queue we experienced slow worker boot
|
157
|
+
times (up to 3 minutes in some cases) combined with disk IO saturation and
|
158
|
+
increased memory consumption. This is due to the fact that a worker in
|
159
|
+
ci-queue has to load every spec file on boot. In applications with a large
|
160
|
+
number of spec files this may result in a significant performance hit and
|
161
|
+
in case of cloud environments, increased costs.
|
162
|
+
|
163
|
+
We also observed slower build times compared to our previous solution which
|
164
|
+
scheduled whole spec files (as opposed to individual examples), due to
|
165
|
+
big differences in runtimes of individual examples, something common in big
|
166
|
+
RSpec suites.
|
167
|
+
|
168
|
+
We decided for RSpecQ to use whole spec files as its main unit of work (as
|
169
|
+
opposed to ci-queue which uses individual examples). This means that an RSpecQ
|
170
|
+
worker only loads the files needed and ends up with a subset of all the suite's
|
171
|
+
files. (Note: RSpecQ also schedules individual examples, but only when this is
|
172
|
+
deemed necessary, see [Spec file splitting](#spec-file-splitting)).
|
173
|
+
|
174
|
+
This kept boot and test run times considerably fast. As a side benefit, this
|
175
|
+
allows suites to keep using `before(:all)` hooks (which ci-queue explicitly
|
176
|
+
rejects).
|
177
|
+
|
178
|
+
The downside of this design is that it's more complicated, since the scheduling
|
179
|
+
of spec files happens based on timings calculated from previous runs. This
|
180
|
+
means that RSpecQ maintains a key with the timing of each job and updates it
|
181
|
+
on every run (if the `--update-timings` option was used). Also, RSpecQ has a
|
182
|
+
"slow file threshold" which, currently has to be set manually (but this can be
|
183
|
+
improved in the future).
|
184
|
+
|
185
|
+
|
186
|
+
## Development
|
187
|
+
|
188
|
+
Install the required dependencies:
|
189
|
+
|
190
|
+
```
|
191
|
+
$ bundle install
|
192
|
+
```
|
193
|
+
|
194
|
+
Then you can execute the tests after spinning up a Redis instance at
|
195
|
+
`127.0.0.1:6379`:
|
196
|
+
|
197
|
+
```
|
198
|
+
$ bundle exec rake
|
199
|
+
```
|
200
|
+
|
201
|
+
To enable verbose output in the tests:
|
202
|
+
|
203
|
+
```
|
204
|
+
$ RSPECQ_DEBUG=1 bundle exec rake
|
205
|
+
```
|
98
206
|
|
99
|
-
This protects us against unrecoverable worker failures (e.g. segfault).
|
100
207
|
|
101
208
|
## License
|
102
209
|
|
data/Rakefile
ADDED
data/bin/rspecq
CHANGED
@@ -1,67 +1,138 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
require "
|
2
|
+
require "optparse"
|
3
3
|
require "rspecq"
|
4
4
|
|
5
|
+
DEFAULT_REDIS_HOST = "127.0.0.1"
|
6
|
+
DEFAULT_REPORT_TIMEOUT = 3600 # 1 hour
|
7
|
+
DEFAULT_MAX_REQUEUES = 3
|
8
|
+
|
9
|
+
def env_set?(var)
|
10
|
+
["1", "true"].include?(ENV[var])
|
11
|
+
end
|
12
|
+
|
5
13
|
opts = {}
|
14
|
+
|
6
15
|
OptionParser.new do |o|
|
7
|
-
|
16
|
+
name = File.basename($PROGRAM_NAME)
|
17
|
+
|
18
|
+
o.banner = <<~BANNER
|
19
|
+
NAME:
|
20
|
+
#{name} - Optimally distribute and run RSpec suites among parallel workers
|
21
|
+
|
22
|
+
USAGE:
|
23
|
+
#{name} [<options>] [spec files or directories]
|
24
|
+
BANNER
|
25
|
+
|
26
|
+
o.separator ""
|
27
|
+
o.separator "OPTIONS:"
|
28
|
+
|
29
|
+
o.on("-b", "--build ID", "A unique identifier for the build. Should be " \
|
30
|
+
"common among workers participating in the same build.") do |v|
|
31
|
+
opts[:build] = v
|
32
|
+
end
|
33
|
+
|
34
|
+
o.on("-w", "--worker ID", "An identifier for the worker. Workers " \
|
35
|
+
"participating in the same build should have distinct IDs.") do |v|
|
36
|
+
opts[:worker] = v
|
37
|
+
end
|
8
38
|
|
9
|
-
o.on("
|
10
|
-
|
39
|
+
o.on("-r", "--redis HOST", "Redis host to connect to " \
|
40
|
+
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
41
|
+
puts "--redis is deprecated. Use --redis-host or --redis-url instead"
|
42
|
+
opts[:redis_host] = v
|
11
43
|
end
|
12
44
|
|
13
|
-
o.on("--
|
14
|
-
|
45
|
+
o.on("--redis-host HOST", "Redis host to connect to " \
|
46
|
+
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
47
|
+
opts[:redis_host] = v
|
15
48
|
end
|
16
49
|
|
17
|
-
o.on("--redis
|
18
|
-
|
50
|
+
o.on("--redis-url URL", "The URL of the Redis host to connect to " \
|
51
|
+
"(e.g.: redis://127.0.0.1:6379/0).") do |v|
|
52
|
+
opts[:redis_url] = v
|
19
53
|
end
|
20
54
|
|
21
|
-
o.on("--timings", "
|
55
|
+
o.on("--update-timings", "Update the global job timings key with the " \
|
56
|
+
"timings of this build. Note: This key is used as the basis for job " \
|
57
|
+
"scheduling.") do |v|
|
22
58
|
opts[:timings] = v
|
23
59
|
end
|
24
60
|
|
25
|
-
o.on("--file-split-threshold N", "Split spec files slower than N
|
26
|
-
"schedule them
|
27
|
-
opts[:file_split_threshold] =
|
61
|
+
o.on("--file-split-threshold N", Integer, "Split spec files slower than N " \
|
62
|
+
"seconds and schedule them as individual examples.") do |v|
|
63
|
+
opts[:file_split_threshold] = v
|
28
64
|
end
|
29
65
|
|
30
|
-
o.on("--report", "
|
31
|
-
|
66
|
+
o.on("--report", "Enable reporter mode: do not pull tests off the queue; " \
|
67
|
+
"instead print build progress and exit when it's " \
|
68
|
+
"finished.\n#{o.summary_indent*9} " \
|
69
|
+
"Exits with a non-zero status code if there were any " \
|
70
|
+
"failures.") do |v|
|
32
71
|
opts[:report] = v
|
33
72
|
end
|
34
73
|
|
35
|
-
o.on("--report-timeout N", Integer, "Fail if
|
36
|
-
"N seconds. Only applicable if --report is enabled "
|
37
|
-
"(default:
|
74
|
+
o.on("--report-timeout N", Integer, "Fail if build is not finished after " \
|
75
|
+
"N seconds. Only applicable if --report is enabled " \
|
76
|
+
"(default: #{DEFAULT_REPORT_TIMEOUT}).") do |v|
|
38
77
|
opts[:report_timeout] = v
|
39
78
|
end
|
40
79
|
|
80
|
+
o.on("--max-requeues N", Integer, "Retry failed examples up to N times " \
|
81
|
+
"before considering them legit failures " \
|
82
|
+
"(default: #{DEFAULT_MAX_REQUEUES}).") do |v|
|
83
|
+
opts[:max_requeues] = v
|
84
|
+
end
|
85
|
+
|
86
|
+
o.on_tail("-h", "--help", "Show this message.") do
|
87
|
+
puts o
|
88
|
+
exit
|
89
|
+
end
|
90
|
+
|
91
|
+
o.on_tail("-v", "--version", "Print the version and exit.") do
|
92
|
+
puts "#{name} #{RSpecQ::VERSION}"
|
93
|
+
exit
|
94
|
+
end
|
41
95
|
end.parse!
|
42
96
|
|
43
|
-
[:
|
44
|
-
|
97
|
+
opts[:build] ||= ENV["RSPECQ_BUILD"]
|
98
|
+
opts[:worker] ||= ENV["RSPECQ_WORKER"]
|
99
|
+
opts[:redis_host] ||= ENV["RSPECQ_REDIS"] || DEFAULT_REDIS_HOST
|
100
|
+
opts[:timings] ||= env_set?("RSPECQ_UPDATE_TIMINGS")
|
101
|
+
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] || 9999999)
|
102
|
+
opts[:report] ||= env_set?("RSPECQ_REPORT")
|
103
|
+
opts[:report_timeout] ||= Integer(ENV["RSPECQ_REPORT_TIMEOUT"] || DEFAULT_REPORT_TIMEOUT)
|
104
|
+
opts[:max_requeues] ||= Integer(ENV["RSPECQ_MAX_REQUEUES"] || DEFAULT_MAX_REQUEUES)
|
105
|
+
opts[:redis_url] ||= ENV["RSPECQ_REDIS_URL"]
|
106
|
+
|
107
|
+
raise OptionParser::MissingArgument.new(:build) if opts[:build].nil?
|
108
|
+
raise OptionParser::MissingArgument.new(:worker) if !opts[:report] && opts[:worker].nil?
|
109
|
+
|
110
|
+
redis_opts = {}
|
111
|
+
|
112
|
+
if opts[:redis_url]
|
113
|
+
redis_opts[:url] = opts[:redis_url]
|
114
|
+
else
|
115
|
+
redis_opts[:host] = opts[:redis_host]
|
45
116
|
end
|
46
117
|
|
47
118
|
if opts[:report]
|
48
119
|
reporter = RSpecQ::Reporter.new(
|
49
|
-
build_id: opts[:
|
50
|
-
|
51
|
-
|
52
|
-
redis_host: opts[:redis_host],
|
120
|
+
build_id: opts[:build],
|
121
|
+
timeout: opts[:report_timeout],
|
122
|
+
redis_opts: redis_opts,
|
53
123
|
)
|
54
124
|
|
55
125
|
reporter.report
|
56
126
|
else
|
57
127
|
worker = RSpecQ::Worker.new(
|
58
|
-
build_id: opts[:
|
59
|
-
worker_id: opts[:
|
60
|
-
|
61
|
-
files_or_dirs_to_run: ARGV[0] || "spec",
|
128
|
+
build_id: opts[:build],
|
129
|
+
worker_id: opts[:worker],
|
130
|
+
redis_opts: redis_opts
|
62
131
|
)
|
63
132
|
|
133
|
+
worker.files_or_dirs_to_run = ARGV[0] if ARGV[0]
|
64
134
|
worker.populate_timings = opts[:timings]
|
65
|
-
worker.file_split_threshold = opts[:file_split_threshold]
|
135
|
+
worker.file_split_threshold = opts[:file_split_threshold]
|
136
|
+
worker.max_requeues = opts[:max_requeues]
|
66
137
|
worker.work
|
67
138
|
end
|
data/lib/rspecq.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
require "rspec/core"
|
2
|
+
require "sentry-raven"
|
2
3
|
|
3
4
|
module RSpecQ
|
4
|
-
|
5
|
-
|
6
|
-
#
|
7
|
-
# (in seconds), it is considered dead and its reserved work will be put back
|
8
|
-
# to the queue, to be picked up by another worker.
|
5
|
+
# If a worker haven't executed an example for more than WORKER_LIVENESS_SEC
|
6
|
+
# seconds, it is considered dead and its reserved work will be put back
|
7
|
+
# to the queue to be picked up by another worker.
|
9
8
|
WORKER_LIVENESS_SEC = 60.0
|
10
9
|
end
|
11
10
|
|
@@ -16,6 +15,5 @@ require_relative "rspecq/formatters/worker_heartbeat_recorder"
|
|
16
15
|
|
17
16
|
require_relative "rspecq/queue"
|
18
17
|
require_relative "rspecq/reporter"
|
19
|
-
require_relative "rspecq/worker"
|
20
|
-
|
21
18
|
require_relative "rspecq/version"
|
19
|
+
require_relative "rspecq/worker"
|
@@ -1,11 +1,12 @@
|
|
1
1
|
module RSpecQ
|
2
2
|
module Formatters
|
3
3
|
class FailureRecorder
|
4
|
-
def initialize(queue, job)
|
4
|
+
def initialize(queue, job, max_requeues)
|
5
5
|
@queue = queue
|
6
6
|
@job = job
|
7
7
|
@colorizer = RSpec::Core::Formatters::ConsoleCodes
|
8
8
|
@non_example_error_recorded = false
|
9
|
+
@max_requeues = max_requeues
|
9
10
|
end
|
10
11
|
|
11
12
|
# Here we're notified about errors occuring outside of examples.
|
@@ -24,7 +25,7 @@ module RSpecQ
|
|
24
25
|
def example_failed(notification)
|
25
26
|
example = notification.example
|
26
27
|
|
27
|
-
if @queue.requeue_job(example.id,
|
28
|
+
if @queue.requeue_job(example.id, @max_requeues)
|
28
29
|
# HACK: try to avoid picking the job we just requeued; we want it
|
29
30
|
# to be picked up by a different worker
|
30
31
|
sleep 0.5
|
data/lib/rspecq/queue.rb
CHANGED
@@ -1,6 +1,17 @@
|
|
1
1
|
require "redis"
|
2
2
|
|
3
3
|
module RSpecQ
|
4
|
+
# Queue is the data store interface (Redis) and is used to manage the work
|
5
|
+
# queue for a particular build. All Redis operations happen via Queue.
|
6
|
+
#
|
7
|
+
# A queue typically contains all the data needed for a particular build to
|
8
|
+
# happen. These include (but are not limited to) the following:
|
9
|
+
#
|
10
|
+
# - the list of jobs (spec files and/or examples) to be executed
|
11
|
+
# - the failed examples along with their backtrace
|
12
|
+
# - the set of running jobs
|
13
|
+
# - previous job timing statistics used to optimally schedule the jobs
|
14
|
+
# - the set of executed jobs
|
4
15
|
class Queue
|
5
16
|
RESERVE_JOB = <<~LUA.freeze
|
6
17
|
local queue = KEYS[1]
|
@@ -57,10 +68,12 @@ module RSpecQ
|
|
57
68
|
STATUS_INITIALIZING = "initializing".freeze
|
58
69
|
STATUS_READY = "ready".freeze
|
59
70
|
|
60
|
-
|
71
|
+
attr_reader :redis
|
72
|
+
|
73
|
+
def initialize(build_id, worker_id, redis_opts)
|
61
74
|
@build_id = build_id
|
62
75
|
@worker_id = worker_id
|
63
|
-
@redis = Redis.new(
|
76
|
+
@redis = Redis.new(redis_opts.merge(id: worker_id))
|
64
77
|
end
|
65
78
|
|
66
79
|
# NOTE: jobs will be processed from head to tail (lpop)
|
@@ -150,13 +163,21 @@ module RSpecQ
|
|
150
163
|
end
|
151
164
|
|
152
165
|
def example_count
|
153
|
-
@redis.get(key_example_count)
|
166
|
+
@redis.get(key_example_count).to_i
|
154
167
|
end
|
155
168
|
|
156
169
|
def processed_jobs_count
|
157
170
|
@redis.scard(key_queue_processed)
|
158
171
|
end
|
159
172
|
|
173
|
+
def processed_jobs
|
174
|
+
@redis.smembers(key_queue_processed)
|
175
|
+
end
|
176
|
+
|
177
|
+
def requeued_jobs
|
178
|
+
@redis.hgetall(key_requeues)
|
179
|
+
end
|
180
|
+
|
160
181
|
def become_master
|
161
182
|
@redis.setnx(key_queue_status, STATUS_INITIALIZING)
|
162
183
|
end
|
@@ -174,6 +195,7 @@ module RSpecQ
|
|
174
195
|
@redis.hgetall(key_errors)
|
175
196
|
end
|
176
197
|
|
198
|
+
# True if the build is complete, false otherwise
|
177
199
|
def exhausted?
|
178
200
|
return false if !published?
|
179
201
|
|
@@ -200,10 +222,23 @@ module RSpecQ
|
|
200
222
|
exhausted? && example_failures.empty? && non_example_errors.empty?
|
201
223
|
end
|
202
224
|
|
203
|
-
|
225
|
+
# The remaining jobs to be processed. Jobs at the head of the list will
|
226
|
+
# be procesed first.
|
227
|
+
def unprocessed_jobs
|
228
|
+
@redis.lrange(key_queue_unprocessed, 0, -1)
|
229
|
+
end
|
204
230
|
|
205
|
-
|
206
|
-
|
231
|
+
# Returns the jobs considered flaky (i.e. initially failed but passed
|
232
|
+
# after being retried). Must be called after the build is complete,
|
233
|
+
# otherwise an exception will be raised.
|
234
|
+
def flaky_jobs
|
235
|
+
raise "Queue is not yet exhausted" if !exhausted?
|
236
|
+
|
237
|
+
requeued = @redis.hkeys(key_requeues)
|
238
|
+
|
239
|
+
return [] if requeued.empty?
|
240
|
+
|
241
|
+
requeued - @redis.hkeys(key_failures)
|
207
242
|
end
|
208
243
|
|
209
244
|
# redis: STRING [STATUS_INITIALIZING, STATUS_READY]
|
@@ -279,6 +314,12 @@ module RSpecQ
|
|
279
314
|
"build_times"
|
280
315
|
end
|
281
316
|
|
317
|
+
private
|
318
|
+
|
319
|
+
def key(*keys)
|
320
|
+
[@build_id, keys].join(":")
|
321
|
+
end
|
322
|
+
|
282
323
|
# We don't use any Ruby `Time` methods because specs that use timecop in
|
283
324
|
# before(:all) hooks will mess up our times.
|
284
325
|
def current_time
|
data/lib/rspecq/reporter.rb
CHANGED
@@ -1,10 +1,18 @@
|
|
1
1
|
module RSpecQ
|
2
|
+
# A Reporter, given a build ID, is responsible for consolidating the results
|
3
|
+
# from different workers and printing a complete build summary to the user,
|
4
|
+
# along with any failures that might have occured.
|
5
|
+
#
|
6
|
+
# The failures are printed in real-time as they occur, while the final
|
7
|
+
# summary is printed after the queue is empty and no tests are being
|
8
|
+
# executed. If the build failed, the status code of the reporter is non-zero.
|
9
|
+
#
|
10
|
+
# Reporters are readers of the queue.
|
2
11
|
class Reporter
|
3
|
-
def initialize(build_id:,
|
12
|
+
def initialize(build_id:, timeout:, redis_opts:)
|
4
13
|
@build_id = build_id
|
5
|
-
@worker_id = worker_id
|
6
14
|
@timeout = timeout
|
7
|
-
@queue = Queue.new(build_id,
|
15
|
+
@queue = Queue.new(build_id, "reporter", redis_opts)
|
8
16
|
|
9
17
|
# We want feedback to be immediattely printed to CI users, so
|
10
18
|
# we disable buffering.
|
@@ -12,7 +20,7 @@ module RSpecQ
|
|
12
20
|
end
|
13
21
|
|
14
22
|
def report
|
15
|
-
|
23
|
+
@queue.wait_until_published
|
16
24
|
|
17
25
|
finished = false
|
18
26
|
|
@@ -46,8 +54,13 @@ module RSpecQ
|
|
46
54
|
raise "Build not finished after #{@timeout} seconds" if !finished
|
47
55
|
|
48
56
|
@queue.record_build_time(tests_duration)
|
57
|
+
|
58
|
+
flaky_jobs = @queue.flaky_jobs
|
59
|
+
|
49
60
|
puts summary(@queue.example_failures, @queue.non_example_errors,
|
50
|
-
|
61
|
+
flaky_jobs, humanize_duration(tests_duration))
|
62
|
+
|
63
|
+
flaky_jobs_to_sentry(flaky_jobs, tests_duration)
|
51
64
|
|
52
65
|
exit 1 if !@queue.build_successful?
|
53
66
|
end
|
@@ -61,7 +74,7 @@ module RSpecQ
|
|
61
74
|
end
|
62
75
|
|
63
76
|
# We try to keep this output consistent with RSpec's original output
|
64
|
-
def summary(failures, errors, duration)
|
77
|
+
def summary(failures, errors, flaky_jobs, duration)
|
65
78
|
failed_examples_section = "\nFailed examples:\n\n"
|
66
79
|
|
67
80
|
failures.each do |_job, msg|
|
@@ -82,6 +95,14 @@ module RSpecQ
|
|
82
95
|
"#{errors.count} errors"
|
83
96
|
summary << "\n\n"
|
84
97
|
summary << "Spec execution time: #{duration}"
|
98
|
+
|
99
|
+
if !flaky_jobs.empty?
|
100
|
+
summary << "\n\n"
|
101
|
+
summary << "Flaky jobs detected (count=#{flaky_jobs.count}):\n"
|
102
|
+
flaky_jobs.each { |j| summary << " #{j}\n" }
|
103
|
+
end
|
104
|
+
|
105
|
+
summary
|
85
106
|
end
|
86
107
|
|
87
108
|
def failure_formatted(rspec_output)
|
@@ -91,5 +112,35 @@ module RSpecQ
|
|
91
112
|
def humanize_duration(seconds)
|
92
113
|
Time.at(seconds).utc.strftime("%H:%M:%S")
|
93
114
|
end
|
115
|
+
|
116
|
+
def flaky_jobs_to_sentry(jobs, build_duration)
|
117
|
+
return if jobs.empty?
|
118
|
+
|
119
|
+
jobs.each do |job|
|
120
|
+
filename = job.sub(/\[.+\]/, '')
|
121
|
+
|
122
|
+
extra = {
|
123
|
+
build: @build_id,
|
124
|
+
build_timeout: @timeout,
|
125
|
+
queue: @queue.inspect,
|
126
|
+
object: self.inspect,
|
127
|
+
pid: Process.pid,
|
128
|
+
job_path: job,
|
129
|
+
build_duration: build_duration
|
130
|
+
}
|
131
|
+
|
132
|
+
tags = {
|
133
|
+
flaky: true,
|
134
|
+
spec_file: filename
|
135
|
+
}
|
136
|
+
|
137
|
+
Raven.capture_message(
|
138
|
+
"Flaky test in #{filename}",
|
139
|
+
level: 'warning',
|
140
|
+
extra: extra,
|
141
|
+
tags: tags
|
142
|
+
)
|
143
|
+
end
|
144
|
+
end
|
94
145
|
end
|
95
146
|
end
|
data/lib/rspecq/version.rb
CHANGED
data/lib/rspecq/worker.rb
CHANGED
@@ -1,10 +1,28 @@
|
|
1
1
|
require "json"
|
2
|
+
require "pathname"
|
2
3
|
require "pp"
|
4
|
+
require "open3"
|
3
5
|
|
4
6
|
module RSpecQ
|
7
|
+
# A Worker, given a build ID, continuously consumes tests off the
|
8
|
+
# corresponding and executes them, until the queue is empty.
|
9
|
+
# It is also responsible for populating the initial queue.
|
10
|
+
#
|
11
|
+
# Essentially, a worker is an RSpec runner that prints the results of the
|
12
|
+
# tests it executes to standard output.
|
13
|
+
#
|
14
|
+
# The typical use case is to spawn many workers for a given build, thereby
|
15
|
+
# parallelizing the work and achieving faster build times.
|
16
|
+
#
|
17
|
+
# Workers are readers+writers of the queue.
|
5
18
|
class Worker
|
6
19
|
HEARTBEAT_FREQUENCY = WORKER_LIVENESS_SEC / 6
|
7
20
|
|
21
|
+
# The root path or individual spec files to execute.
|
22
|
+
#
|
23
|
+
# Defaults to "spec" (similar to RSpec)
|
24
|
+
attr_accessor :files_or_dirs_to_run
|
25
|
+
|
8
26
|
# If true, job timings will be populated in the global Redis timings key
|
9
27
|
#
|
10
28
|
# Defaults to false
|
@@ -12,15 +30,27 @@ module RSpecQ
|
|
12
30
|
|
13
31
|
# If set, spec files that are known to take more than this value to finish,
|
14
32
|
# will be split and scheduled on a per-example basis.
|
33
|
+
#
|
34
|
+
# Defaults to 999999
|
15
35
|
attr_accessor :file_split_threshold
|
16
36
|
|
17
|
-
|
37
|
+
# Retry failed examples up to N times (with N being the supplied value)
|
38
|
+
# before considering them legit failures
|
39
|
+
#
|
40
|
+
# Defaults to 3
|
41
|
+
attr_accessor :max_requeues
|
42
|
+
|
43
|
+
attr_reader :queue
|
44
|
+
|
45
|
+
def initialize(build_id:, worker_id:, redis_opts:)
|
18
46
|
@build_id = build_id
|
19
47
|
@worker_id = worker_id
|
20
|
-
@queue = Queue.new(build_id, worker_id,
|
21
|
-
@files_or_dirs_to_run =
|
48
|
+
@queue = Queue.new(build_id, worker_id, redis_opts)
|
49
|
+
@files_or_dirs_to_run = "spec"
|
22
50
|
@populate_timings = false
|
23
51
|
@file_split_threshold = 999999
|
52
|
+
@heartbeat_updated_at = nil
|
53
|
+
@max_requeues = 3
|
24
54
|
|
25
55
|
RSpec::Core::Formatters.register(Formatters::JobTimingRecorder, :dump_summary)
|
26
56
|
RSpec::Core::Formatters.register(Formatters::ExampleCountRecorder, :dump_summary)
|
@@ -31,23 +61,23 @@ module RSpecQ
|
|
31
61
|
def work
|
32
62
|
puts "Working for build #{@build_id} (worker=#{@worker_id})"
|
33
63
|
|
34
|
-
try_publish_queue!(
|
35
|
-
|
64
|
+
try_publish_queue!(queue)
|
65
|
+
queue.wait_until_published
|
36
66
|
|
37
67
|
loop do
|
38
68
|
# we have to bootstrap this so that it can be used in the first call
|
39
69
|
# to `requeue_lost_job` inside the work loop
|
40
70
|
update_heartbeat
|
41
71
|
|
42
|
-
lost =
|
72
|
+
lost = queue.requeue_lost_job
|
43
73
|
puts "Requeued lost job: #{lost}" if lost
|
44
74
|
|
45
75
|
# TODO: can we make `reserve_job` also act like exhausted? and get
|
46
76
|
# rid of `exhausted?` (i.e. return false if no jobs remain)
|
47
|
-
job =
|
77
|
+
job = queue.reserve_job
|
48
78
|
|
49
79
|
# build is finished
|
50
|
-
return if job.nil? &&
|
80
|
+
return if job.nil? && queue.exhausted?
|
51
81
|
|
52
82
|
next if job.nil?
|
53
83
|
|
@@ -60,112 +90,125 @@ module RSpecQ
|
|
60
90
|
RSpec.configuration.detail_color = :magenta
|
61
91
|
RSpec.configuration.seed = srand && srand % 0xFFFF
|
62
92
|
RSpec.configuration.backtrace_formatter.filter_gem('rspecq')
|
63
|
-
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(
|
64
|
-
RSpec.configuration.add_formatter(Formatters::ExampleCountRecorder.new(
|
93
|
+
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues))
|
94
|
+
RSpec.configuration.add_formatter(Formatters::ExampleCountRecorder.new(queue))
|
65
95
|
RSpec.configuration.add_formatter(Formatters::WorkerHeartbeatRecorder.new(self))
|
66
96
|
|
67
97
|
if populate_timings
|
68
|
-
RSpec.configuration.add_formatter(Formatters::JobTimingRecorder.new(
|
98
|
+
RSpec.configuration.add_formatter(Formatters::JobTimingRecorder.new(queue, job))
|
69
99
|
end
|
70
100
|
|
71
101
|
opts = RSpec::Core::ConfigurationOptions.new(["--format", "progress", job])
|
72
102
|
_result = RSpec::Core::Runner.new(opts).run($stderr, $stdout)
|
73
103
|
|
74
|
-
|
104
|
+
queue.acknowledge_job(job)
|
75
105
|
end
|
76
106
|
end
|
77
107
|
|
78
108
|
# Update the worker heartbeat if necessary
|
79
109
|
def update_heartbeat
|
80
110
|
if @heartbeat_updated_at.nil? || elapsed(@heartbeat_updated_at) >= HEARTBEAT_FREQUENCY
|
81
|
-
|
111
|
+
queue.record_worker_heartbeat
|
82
112
|
@heartbeat_updated_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
83
113
|
end
|
84
114
|
end
|
85
115
|
|
86
|
-
private
|
87
|
-
|
88
|
-
def reset_rspec_state!
|
89
|
-
RSpec.clear_examples
|
90
|
-
|
91
|
-
# TODO: remove after https://github.com/rspec/rspec-core/pull/2723
|
92
|
-
RSpec.world.instance_variable_set(:@example_group_counts_by_spec_file, Hash.new(0))
|
93
|
-
|
94
|
-
# RSpec.clear_examples does not reset those, which causes issues when
|
95
|
-
# a non-example error occurs (subsequent jobs are not executed)
|
96
|
-
# TODO: upstream
|
97
|
-
RSpec.world.non_example_failure = false
|
98
|
-
|
99
|
-
# we don't want an error that occured outside of the examples (which
|
100
|
-
# would set this to `true`) to stop the worker
|
101
|
-
RSpec.world.wants_to_quit = false
|
102
|
-
end
|
103
|
-
|
104
116
|
def try_publish_queue!(queue)
|
105
117
|
return if !queue.become_master
|
106
118
|
|
107
|
-
RSpec.configuration.files_or_directories_to_run =
|
119
|
+
RSpec.configuration.files_or_directories_to_run = files_or_dirs_to_run
|
108
120
|
files_to_run = RSpec.configuration.files_to_run.map { |j| relative_path(j) }
|
109
121
|
|
110
122
|
timings = queue.timings
|
111
123
|
if timings.empty?
|
112
|
-
# TODO: should be a warning reported somewhere (Sentry?)
|
113
124
|
q_size = queue.publish(files_to_run.shuffle)
|
114
|
-
|
115
|
-
|
125
|
+
log_event(
|
126
|
+
"No timings found! Published queue in random order (size=#{q_size})",
|
127
|
+
"warning"
|
128
|
+
)
|
116
129
|
return
|
117
130
|
end
|
118
131
|
|
119
|
-
|
120
|
-
|
121
|
-
|
132
|
+
# prepare jobs to run
|
133
|
+
jobs = []
|
134
|
+
slow_files = []
|
122
135
|
|
123
|
-
if
|
124
|
-
|
136
|
+
if file_split_threshold
|
137
|
+
slow_files = timings.take_while do |_job, duration|
|
138
|
+
duration >= file_split_threshold
|
139
|
+
end.map(&:first) & files_to_run
|
125
140
|
end
|
126
141
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
142
|
+
if slow_files.any?
|
143
|
+
jobs.concat(files_to_run - slow_files)
|
144
|
+
jobs.concat(files_to_example_ids(slow_files))
|
145
|
+
else
|
146
|
+
jobs.concat(files_to_run)
|
147
|
+
end
|
131
148
|
|
132
|
-
# assign timings to all of them
|
133
149
|
default_timing = timings.values[timings.values.size/2]
|
134
150
|
|
151
|
+
# assign timings (based on previous runs) to all jobs
|
135
152
|
jobs = jobs.each_with_object({}) do |j, h|
|
136
|
-
|
137
|
-
|
153
|
+
puts "Untimed job: #{j}" if timings[j].nil?
|
154
|
+
|
155
|
+
# HEURISTIC: put jobs without previous timings (e.g. a newly added
|
156
|
+
# spec file) in the middle of the queue
|
138
157
|
h[j] = timings[j] || default_timing
|
139
158
|
end
|
140
159
|
|
141
|
-
#
|
160
|
+
# sort jobs based on their timings (slowest to be processed first)
|
142
161
|
jobs = jobs.sort_by { |_j, t| -t }.map(&:first)
|
143
162
|
|
144
163
|
puts "Published queue (size=#{queue.publish(jobs)})"
|
145
164
|
end
|
146
165
|
|
166
|
+
private
|
167
|
+
|
168
|
+
def reset_rspec_state!
|
169
|
+
RSpec.clear_examples
|
170
|
+
|
171
|
+
# see https://github.com/rspec/rspec-core/pull/2723
|
172
|
+
if Gem::Version.new(RSpec::Core::Version::STRING) <= Gem::Version.new("3.9.1")
|
173
|
+
RSpec.world.instance_variable_set(
|
174
|
+
:@example_group_counts_by_spec_file, Hash.new(0))
|
175
|
+
end
|
176
|
+
|
177
|
+
# RSpec.clear_examples does not reset those, which causes issues when
|
178
|
+
# a non-example error occurs (subsequent jobs are not executed)
|
179
|
+
# TODO: upstream
|
180
|
+
RSpec.world.non_example_failure = false
|
181
|
+
|
182
|
+
# we don't want an error that occured outside of the examples (which
|
183
|
+
# would set this to `true`) to stop the worker
|
184
|
+
RSpec.world.wants_to_quit = false
|
185
|
+
end
|
186
|
+
|
147
187
|
# NOTE: RSpec has to load the files before we can split them as individual
|
148
188
|
# examples. In case a file to be splitted fails to be loaded
|
149
|
-
# (e.g. contains a syntax error), we return the
|
150
|
-
#
|
151
|
-
#
|
152
|
-
# Their errors will be reported in the normal flow, when they're picked up
|
153
|
-
# as jobs by a worker.
|
189
|
+
# (e.g. contains a syntax error), we return the files unchanged, thereby
|
190
|
+
# falling back to scheduling them as whole files. Their errors will be
|
191
|
+
# reported in the normal flow when they're eventually picked up by a worker.
|
154
192
|
def files_to_example_ids(files)
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
193
|
+
cmd = "DISABLE_SPRING=1 bundle exec rspec --dry-run --format json #{files.join(' ')}"
|
194
|
+
out, err, cmd_result = Open3.capture3(cmd)
|
195
|
+
|
196
|
+
if !cmd_result.success?
|
197
|
+
rspec_output = begin
|
198
|
+
JSON.parse(out)
|
199
|
+
rescue JSON::ParserError
|
200
|
+
out
|
201
|
+
end
|
202
|
+
|
203
|
+
log_event(
|
204
|
+
"Failed to split slow files, falling back to regular scheduling.\n #{err}",
|
205
|
+
"error",
|
206
|
+
rspec_stdout: rspec_output,
|
207
|
+
rspec_stderr: err,
|
208
|
+
cmd_result: cmd_result.inspect,
|
209
|
+
)
|
210
|
+
|
211
|
+
pp rspec_output
|
169
212
|
|
170
213
|
return files
|
171
214
|
end
|
@@ -181,5 +224,23 @@ module RSpecQ
|
|
181
224
|
def elapsed(since)
|
182
225
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) - since
|
183
226
|
end
|
227
|
+
|
228
|
+
# Prints msg to standard output and emits an event to Sentry, if the
|
229
|
+
# SENTRY_DSN environment variable is set.
|
230
|
+
def log_event(msg, level, additional={})
|
231
|
+
puts msg
|
232
|
+
|
233
|
+
Raven.capture_message(msg, level: level, extra: {
|
234
|
+
build: @build_id,
|
235
|
+
worker: @worker_id,
|
236
|
+
queue: queue.inspect,
|
237
|
+
files_or_dirs_to_run: files_or_dirs_to_run,
|
238
|
+
populate_timings: populate_timings,
|
239
|
+
file_split_threshold: file_split_threshold,
|
240
|
+
heartbeat_updated_at: @heartbeat_updated_at,
|
241
|
+
object: self.inspect,
|
242
|
+
pid: Process.pid,
|
243
|
+
}.merge(additional))
|
244
|
+
end
|
184
245
|
end
|
185
246
|
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.0
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Agis Anastasopoulos
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-10-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec-core
|
@@ -38,22 +38,64 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: sentry-raven
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry-byebug
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
41
83
|
- !ruby/object:Gem::Dependency
|
42
84
|
name: minitest
|
43
85
|
requirement: !ruby/object:Gem::Requirement
|
44
86
|
requirements:
|
45
|
-
- - "
|
87
|
+
- - ">="
|
46
88
|
- !ruby/object:Gem::Version
|
47
|
-
version: '
|
89
|
+
version: '0'
|
48
90
|
type: :development
|
49
91
|
prerelease: false
|
50
92
|
version_requirements: !ruby/object:Gem::Requirement
|
51
93
|
requirements:
|
52
|
-
- - "
|
94
|
+
- - ">="
|
53
95
|
- !ruby/object:Gem::Version
|
54
|
-
version: '
|
96
|
+
version: '0'
|
55
97
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
98
|
+
name: rspec
|
57
99
|
requirement: !ruby/object:Gem::Requirement
|
58
100
|
requirements:
|
59
101
|
- - ">="
|
@@ -76,8 +118,10 @@ files:
|
|
76
118
|
- CHANGELOG.md
|
77
119
|
- LICENSE
|
78
120
|
- README.md
|
121
|
+
- Rakefile
|
79
122
|
- bin/rspecq
|
80
123
|
- lib/rspecq.rb
|
124
|
+
- lib/rspecq/formatters/README.md
|
81
125
|
- lib/rspecq/formatters/example_count_recorder.rb
|
82
126
|
- lib/rspecq/formatters/failure_recorder.rb
|
83
127
|
- lib/rspecq/formatters/job_timing_recorder.rb
|
@@ -101,12 +145,13 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
145
|
version: '0'
|
102
146
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
147
|
requirements:
|
104
|
-
- - "
|
148
|
+
- - ">="
|
105
149
|
- !ruby/object:Gem::Version
|
106
|
-
version:
|
150
|
+
version: '0'
|
107
151
|
requirements: []
|
108
|
-
rubygems_version: 3.1.
|
152
|
+
rubygems_version: 3.1.4
|
109
153
|
signing_key:
|
110
154
|
specification_version: 4
|
111
|
-
summary:
|
155
|
+
summary: Optimally distribute and run RSpec suites among parallel workers; for faster
|
156
|
+
CI builds
|
112
157
|
test_files: []
|