rspec-hopper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- metadata +137 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7ac7938a8f8be5dfeaa3fe71e48c5f9a09ef34db00b94903a8e89e0862399571
|
|
4
|
+
data.tar.gz: 2f5f71d073fb570c482fd5d57a326cebf5a32e5a444d8a074ec729b4726cd09d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3f8b0fe48942ad2724c23cea372d4a2ebf86bb7008d0e43f9300633f90e54f8ced37a81036c519fa2c0554d2df08582aab8af2f0210c5a1ea443468dc3137efc
|
|
7
|
+
data.tar.gz: 9ce47aee0f8525833c9974a77482ccd010439be6b904cf996086bd5664b14e82d4ac1865e418160481bf0c6166691b6d46ba67c623e98a5c587613099381baaa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-09-11
|
|
6
|
+
|
|
7
|
+
- Initial Phase 1 implementation: file-level work units distributed through
|
|
8
|
+
Redis Streams, requeue and reclaim accounting, an append-only attempt log,
|
|
9
|
+
a `work` subcommand with `--processes` and two boot modes, and a `report`
|
|
10
|
+
subcommand that produces the build verdict.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Simpson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# rspec-hopper
|
|
2
|
+
|
|
3
|
+
rspec-hopper distributes an RSpec suite across many CI workers through a shared
|
|
4
|
+
Redis, requeues flaky work, reclaims work from workers that die, and produces one
|
|
5
|
+
authoritative pass/fail verdict for the whole build.
|
|
6
|
+
|
|
7
|
+
A hopper feeds a machine continuously: workers pull spec files from a shared queue as
|
|
8
|
+
fast as they finish them, and requeued files drop back in ahead of untouched work.
|
|
9
|
+
There is no up-front partitioning, so a slow file or a slow machine only delays its
|
|
10
|
+
own share of the work.
|
|
11
|
+
|
|
12
|
+
## Problems it solves
|
|
13
|
+
|
|
14
|
+
Each of these is a failure mode of the "share a queue in Redis and let every worker
|
|
15
|
+
exit with its own status" approach as it exists in other tools:
|
|
16
|
+
|
|
17
|
+
- **Leaked Redis keys.** Every key the gem writes gets its TTL in the same atomic
|
|
18
|
+
operation that creates it. Killed workers cannot leave keys behind forever.
|
|
19
|
+
- **False green on an empty or vanished queue.** A build whose keys expired, a suite
|
|
20
|
+
that selected zero examples, and a build that finished all look like "nothing left to
|
|
21
|
+
run". The report tells them apart and only passes when a published manifest exists
|
|
22
|
+
and every unit in it finalized as passed.
|
|
23
|
+
- **Fighting other gems over RSpec internals.** Nothing is prepended onto
|
|
24
|
+
`RSpec::Core::Example#run`, `#start` or `#finish`, so instrumentation gems that do
|
|
25
|
+
(datadog-ci, rspec-retry and others) keep working.
|
|
26
|
+
- **Ambiguous exit codes.** Workers exit 0 on completion regardless of test results;
|
|
27
|
+
`rspec-hopper report` is the single place a build's verdict comes from.
|
|
28
|
+
- **`Marshal.load` from a shared Redis.** Everything stored is JSON.
|
|
29
|
+
- **One process per worker.** `--processes N` runs several workers on one machine,
|
|
30
|
+
optionally sharing one application boot.
|
|
31
|
+
- **No `before(:all)` support.** Whole example groups run through
|
|
32
|
+
`ExampleGroup.run`, so context hooks fire as they do under plain `rspec`.
|
|
33
|
+
- **Global mutable configuration.** The gem parses its own flags into a frozen config
|
|
34
|
+
and hands everything else to RSpec's option parser untouched.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add the gem to the test group of your `Gemfile`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
group :test do
|
|
42
|
+
gem "rspec-hopper"
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then `bundle install`. Requirements are listed under [Supported versions](#supported-versions).
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
Run one worker on every CI node. All workers of one CI run share a build id; each
|
|
51
|
+
has a distinct worker id. Everything after `--` (and any argument the gem does not
|
|
52
|
+
recognise) goes to RSpec unchanged.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
# on each of N nodes
|
|
56
|
+
bundle exec rspec-hopper work \
|
|
57
|
+
--build "$CI_BUILD_ID" --worker "$CI_NODE_INDEX" --redis "$REDIS_URL" \
|
|
58
|
+
--max-requeues 2 --requeue-tolerance 0.05 \
|
|
59
|
+
--format RspecJunitFormatter --out tmp/junit.xml -- spec
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Then, from any node or a final job, ask for the verdict. `report` never loads the
|
|
63
|
+
application or the spec files; it only reads Redis, and it can be started before any
|
|
64
|
+
worker.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
bundle exec rspec-hopper report \
|
|
68
|
+
--build "$CI_BUILD_ID" --redis "$REDIS_URL" \
|
|
69
|
+
--summary-out tmp/hopper-summary.json --failed-out tmp/hopper-failed.txt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The report's exit code is the build's result. Use it as the CI job's status. The
|
|
73
|
+
`--failed-out` file holds one spec file per line (failed and never-finalized units), so
|
|
74
|
+
`bundle exec rspec $(cat tmp/hopper-failed.txt)` reruns them locally.
|
|
75
|
+
|
|
76
|
+
`--build`, `--worker` and `--redis` can be omitted when `HOPPER_BUILD_ID`,
|
|
77
|
+
`HOPPER_WORKER_ID` and `HOPPER_REDIS_URL` (or `REDIS_URL`) are set, and the build and
|
|
78
|
+
worker ids fall back to the CI variables listed under
|
|
79
|
+
[CI environment inference](#ci-environment-inference). When nothing names the
|
|
80
|
+
worker, `<hostname>-<pid>` is used.
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
1. Every worker boots the application and loads the spec files with RSpec, exactly as
|
|
85
|
+
`rspec` would, applying `.rspec`, `~/.rspec`, `SPEC_OPTS` and the command line.
|
|
86
|
+
2. The first worker to take a short lease publishes the build: one **unit** per spec
|
|
87
|
+
file that has at least one selected example, plus a manifest (unit and example
|
|
88
|
+
counts, the file arguments, the suite fingerprint, the seed). Every other worker
|
|
89
|
+
waits for the manifest, then checks that its own suite fingerprint matches. A
|
|
90
|
+
worker that does not match exits 2 and names the inputs that differ.
|
|
91
|
+
3. Workers loop: reclaim a unit whose owner stopped heartbeating, or reserve the next
|
|
92
|
+
unit (requeued units first), run all of that file's top-level example groups through
|
|
93
|
+
`ExampleGroup.run`, then finalize the unit as passed or failed, or requeue it.
|
|
94
|
+
4. Formatter output is buffered per attempt and replayed only for the final attempt,
|
|
95
|
+
so JUnit and JSON files contain each example exactly once. Flakiness is recorded in
|
|
96
|
+
the attempt log, not in formatter output.
|
|
97
|
+
5. A worker exits only when every unit of the manifest has been finalized. An empty
|
|
98
|
+
queue is never treated as completion, so idle workers stay available to reclaim
|
|
99
|
+
units from workers that die late.
|
|
100
|
+
6. `report` waits for the same condition and turns the attempt log into a verdict.
|
|
101
|
+
|
|
102
|
+
## Correctness invariants
|
|
103
|
+
|
|
104
|
+
These hold regardless of worker crashes, hung tests, or Redis trouble:
|
|
105
|
+
|
|
106
|
+
- Every selected unit reaches exactly one final state, passed or failed.
|
|
107
|
+
- Execution may happen more than once after a worker is lost; finalization happens
|
|
108
|
+
exactly once. A worker whose ownership was reclaimed can no longer finalize,
|
|
109
|
+
requeue or heartbeat that attempt; its result is discarded and recorded as
|
|
110
|
+
`stale_rejected`.
|
|
111
|
+
- Ownership never moves without reclaim accounting, and there is no crash window
|
|
112
|
+
between the two.
|
|
113
|
+
- Re-execution after worker loss never consumes retry budget and never marks a unit
|
|
114
|
+
flaky.
|
|
115
|
+
- Completion means `finalized_count == total_units`, never "the queue is empty".
|
|
116
|
+
- Once a build is ready, missing build state is corruption. Nothing silently recreates
|
|
117
|
+
it, and a build id whose state has vanished is never reinitialized.
|
|
118
|
+
- A build cannot report green unless a manifest was published and every unit in it
|
|
119
|
+
finalized as passed. Zero selected examples fail unless the reporter is given
|
|
120
|
+
`--allow-empty`.
|
|
121
|
+
- No Redis key created by the gem exists without a positive TTL.
|
|
122
|
+
- Workers with different suite fingerprints never participate in the same build.
|
|
123
|
+
- Requeued work is preferred over untouched work.
|
|
124
|
+
- Redis, application or worker death and hung tests may delay completion; they cannot
|
|
125
|
+
silently change the verdict or prevent completion indefinitely.
|
|
126
|
+
|
|
127
|
+
## Suite hooks run once per worker process
|
|
128
|
+
|
|
129
|
+
`before(:suite)` and `after(:suite)` run once in **every worker process**, not once per
|
|
130
|
+
build. With ten workers, a `before(:suite)` that seeds a database runs ten times, and
|
|
131
|
+
under `--processes 4` it runs four times per machine. The gem provides no build-global
|
|
132
|
+
hook; if something must happen exactly once per build, do it in a CI step before the
|
|
133
|
+
workers start.
|
|
134
|
+
|
|
135
|
+
A `before(:suite)` hook that raises makes that worker exit 2 without running any unit.
|
|
136
|
+
|
|
137
|
+
## CLI reference
|
|
138
|
+
|
|
139
|
+
### `rspec-hopper work`
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
rspec-hopper work --build ID --worker WID --redis URL [options] [rspec args...] -- [files...]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Option | Default | Meaning |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `--build ID` | `$HOPPER_BUILD_ID`, then CI inference | Build id shared by every worker of one CI run. |
|
|
148
|
+
| `--worker WID` | `$HOPPER_WORKER_ID`, CI inference, then `<hostname>-<pid>` | This worker's id, unique within the build. |
|
|
149
|
+
| `--redis URL` | `$HOPPER_REDIS_URL`, then `$REDIS_URL` | Redis URL. |
|
|
150
|
+
| `--revision SHA` | none | String mixed into the suite fingerprint, for example the commit being tested. |
|
|
151
|
+
| `--timeout SECONDS` | 180 | Missed-heartbeat window after which an in-flight unit can be reclaimed by another worker. |
|
|
152
|
+
| `--max-unit-duration SECONDS` | 900 | After this much execution time a unit is recorded as abandoned and becomes reclaimable even though its owner is alive; the owner aborts itself `--timeout` seconds later. Must exceed the slowest legitimate file. |
|
|
153
|
+
| `--max-requeues N` | 0 | Maximum number of retries of any one unit. |
|
|
154
|
+
| `--requeue-tolerance R` | 0 | Fraction of units, `0.0` to `1.0`, allowed to enter retry during the build (`ceil(total_units * R)` units). |
|
|
155
|
+
| `--max-reclaims N` | 3 | Reclaims from dead or abandoning owners before a unit is finalized as failed. |
|
|
156
|
+
| `--processes N` | 1 | Fork N worker processes on this machine. See [Process parallelism](#process-parallelism). |
|
|
157
|
+
| `--boot MODE` | `per-process` | `per-process` or `shared`. |
|
|
158
|
+
| `--report-on-exit` | off | The `--processes` parent runs `report` after all children exit and uses its exit code. |
|
|
159
|
+
| `--ttl SECONDS` | 14400 | Inactivity TTL of the build's live keys. |
|
|
160
|
+
| `--tombstone-ttl SECONDS` | 604800 | Lifetime of the build's tombstone (seven days). |
|
|
161
|
+
| `--init-timeout SECONDS` | 300 | How long a worker waits for another worker to publish the build. |
|
|
162
|
+
|
|
163
|
+
Either retry limit at zero disables requeues; the defaults run every file once. The
|
|
164
|
+
retry, reclaim and timeout values of the worker that publishes the build are recorded
|
|
165
|
+
in the build and enforced for every worker, so give all workers the same flags.
|
|
166
|
+
|
|
167
|
+
Requeue eligibility: a failed attempt is requeued only if every failure in the file is
|
|
168
|
+
requeueable, meaning anything except `SystemExit`, `Interrupt`, `SignalException` and
|
|
169
|
+
`NoMemoryError`. One of those anywhere in the file makes the attempt final. When a
|
|
170
|
+
retry would exceed `--max-requeues` or the tolerance, the failure is final with reason
|
|
171
|
+
`retry_budget_exhausted`. On a requeue the worker prints one line and emits nothing to
|
|
172
|
+
formatters:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
Retrying ./spec/models/foo_spec.rb (retry 1 of 2; next attempt 2): 1 failure
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Unsupported RSpec options are rejected with exit 2 and a message naming the option,
|
|
179
|
+
wherever they come from (`.rspec`, `~/.rspec`, `SPEC_OPTS` or the command line):
|
|
180
|
+
`--fail-fast` (any value), `--only-failures`, `--next-failure`, `--bisect`,
|
|
181
|
+
`--dry-run`, `--drb` and `--init`. Each conflicts with every unit reaching a final
|
|
182
|
+
state or with distributed execution.
|
|
183
|
+
|
|
184
|
+
The seed is chosen by the publishing worker (the user's `--seed` if given, otherwise
|
|
185
|
+
random) and adopted by every worker, so within-file ordering is identical everywhere.
|
|
186
|
+
|
|
187
|
+
### `rspec-hopper report`
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
rspec-hopper report --build ID --redis URL [options]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
| Option | Default | Meaning |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| `--build ID` | `$HOPPER_BUILD_ID` | Build id. |
|
|
196
|
+
| `--redis URL` | `$HOPPER_REDIS_URL`, then `$REDIS_URL` | Redis URL. |
|
|
197
|
+
| `--timeout S` | 1080 | Give up (exit 3) after S seconds without completion. |
|
|
198
|
+
| `--init-timeout S` | 300 | Wait this long for the manifest or tombstone to appear before exiting 2. |
|
|
199
|
+
| `--inactive-timeout S` | 300 | Give up (exit 3) once this long has passed since the later of the build becoming ready and the most recent worker activity. |
|
|
200
|
+
| `--summary-out PATH` | none | Write the JSON summary to PATH. |
|
|
201
|
+
| `--failed-out PATH` | none | Write failed and never-finalized unit ids to PATH, one per line. |
|
|
202
|
+
| `--fail-on-empty` | on | Zero selected examples is a failure. |
|
|
203
|
+
| `--allow-empty` | off | Zero selected examples may pass. Cannot be combined with a positive `--min-examples`. |
|
|
204
|
+
| `--min-examples N` | 0 | Fail unless at least N examples were selected, guarding against a filter that selects almost nothing. |
|
|
205
|
+
|
|
206
|
+
`report` prints the verdict, unit and example totals, failed and never-finalized units
|
|
207
|
+
(with the worker that last held each), flaky and abandoned units, worker errors, and
|
|
208
|
+
recorded load errors when initialization failed. Heartbeats from a long-running unit
|
|
209
|
+
count as worker activity, so a file slower than `--inactive-timeout` does not trip it.
|
|
210
|
+
|
|
211
|
+
The JSON summary has these keys: `build_id`, `state`, `verdict` (`passed`, `failed`,
|
|
212
|
+
`incomplete`, `init_failed`, `missing`, `expired`, `unreachable`), `exit_code`,
|
|
213
|
+
`message`, `total_units`, `total_examples`, `finalized_count`, `failed` (unit id,
|
|
214
|
+
reason, worker id, errors), `flaky`, `never_finalized` (unit id, last worker id),
|
|
215
|
+
`abandoned`, `retry_counts`, `reclaim_counts`, `worker_errors`, `stale_rejections`,
|
|
216
|
+
`workers`, `load_errors`, `file_args`, `seed`, `fingerprint`, `revision`.
|
|
217
|
+
|
|
218
|
+
## Exit codes
|
|
219
|
+
|
|
220
|
+
### `work`, single process
|
|
221
|
+
|
|
222
|
+
| Code | Meaning |
|
|
223
|
+
|---|---|
|
|
224
|
+
| 0 | The build completed. Says nothing about test results; ask `report`. |
|
|
225
|
+
| 2 | Infrastructure failure: boot error, unsupported RSpec option, Redis unreachable, initialization timed out or failed (spec-file load error), previously initialized build id, fingerprint mismatch, build state vanished or corrupt after ready, `--boot shared` without an `after_fork` hook. |
|
|
226
|
+
| 4 | The worker aborted itself because a unit ran past `--max-unit-duration` plus `--timeout`. The cause is a test, not infrastructure; the unit was made reclaimable and the verdict still comes from `report`. |
|
|
227
|
+
|
|
228
|
+
### `work` parent with `--processes N`
|
|
229
|
+
|
|
230
|
+
Precedence is semantic, not numeric.
|
|
231
|
+
|
|
232
|
+
| Mode | Exit code |
|
|
233
|
+
|---|---|
|
|
234
|
+
| without `--report-on-exit` | 2 if any child exited 2 (or died without an exit status, for example killed by a signal); otherwise 4 if any child exited 4; otherwise 0. |
|
|
235
|
+
| with `--report-on-exit` | 2 if any child exited 2, and the report is not run. Otherwise the `report` exit code; a child's 4 is logged as diagnostic only, because the verdict on the reclaimed unit belongs to `report`. |
|
|
236
|
+
|
|
237
|
+
### `report`
|
|
238
|
+
|
|
239
|
+
| Code | Meaning |
|
|
240
|
+
|---|---|
|
|
241
|
+
| 0 | Manifest present with state `ready`, examples selected (or `--allow-empty`) and at least `--min-examples`, every unit finalized as passed, `finalized_count == total_units`. |
|
|
242
|
+
| 1 | Test failures: at least one unit finalized as failed, or zero examples selected without `--allow-empty`, or fewer than `--min-examples`. |
|
|
243
|
+
| 2 | Neither manifest nor tombstone exists after `--init-timeout` (nobody booted), or Redis is unreachable. |
|
|
244
|
+
| 3 | The tombstone exists but the manifest is gone (expired or evicted), initialization failed with spec-file load errors, the build was incomplete at `--timeout`, or workers went inactive before completion. |
|
|
245
|
+
|
|
246
|
+
## Process parallelism
|
|
247
|
+
|
|
248
|
+
`--processes N` forks N workers on one machine with worker ids `<worker>.1` to
|
|
249
|
+
`<worker>.N`. The parent forwards `INT` and `TERM` to the children, waits for all of
|
|
250
|
+
them and exits per the precedence above. Children exit if the parent disappears.
|
|
251
|
+
`--processes 1` runs inline without forking; N above 1 is refused on platforms without
|
|
252
|
+
`fork`.
|
|
253
|
+
|
|
254
|
+
Each child sets `TEST_ENV_NUMBER` the way `parallel_tests` does: empty for the first
|
|
255
|
+
child, `"2"` to `"N"` for the rest. The two boot modes differ in *when* that happens
|
|
256
|
+
relative to loading the application.
|
|
257
|
+
|
|
258
|
+
### `--boot per-process` (default)
|
|
259
|
+
|
|
260
|
+
The parent parses only the gem's flags and forks immediately. Each child sets
|
|
261
|
+
`TEST_ENV_NUMBER`, then applies RSpec options and loads the suite as a standalone
|
|
262
|
+
worker would. Everything the application evaluates while loading, such as a database
|
|
263
|
+
name built from `TEST_ENV_NUMBER` in `database.yml`, sees the child's value. Boot
|
|
264
|
+
happens N times. This mode is correct for any application.
|
|
265
|
+
|
|
266
|
+
Use it when the application has not been audited for forking, boot is cheap, N is
|
|
267
|
+
low, many things consume `TEST_ENV_NUMBER` at boot, or you are debugging a
|
|
268
|
+
shared-mode failure.
|
|
269
|
+
|
|
270
|
+
### `--boot shared` (opt-in)
|
|
271
|
+
|
|
272
|
+
The parent applies RSpec options and loads the suite once with `TEST_ENV_NUMBER`
|
|
273
|
+
unset, runs the `RSpec::Hopper.before_fork` hooks, then forks. Each child sets
|
|
274
|
+
`TEST_ENV_NUMBER`, runs the `RSpec::Hopper.after_fork` hooks, runs the suite hooks and
|
|
275
|
+
consumes the queue. Boot happens once and children share memory copy-on-write.
|
|
276
|
+
|
|
277
|
+
Use it when boot is a large share of per-process wall clock and N is high, memory is
|
|
278
|
+
the constraint, or the application is already fork-hardened.
|
|
279
|
+
|
|
280
|
+
The gem cannot make this mode safe on the application's behalf: anything computed from
|
|
281
|
+
`TEST_ENV_NUMBER` while the application loaded was computed with it unset. It refuses
|
|
282
|
+
to start, with exit 2, unless at least one `after_fork` hook is registered. In the
|
|
283
|
+
hooks you must:
|
|
284
|
+
|
|
285
|
+
- `before_fork`: close every connection opened during boot (database, Redis, HTTP
|
|
286
|
+
keep-alive pools), so children do not inherit shared sockets.
|
|
287
|
+
- `after_fork`: re-derive every value that was computed from `TEST_ENV_NUMBER`.
|
|
288
|
+
Common consumers to check: the database name, the Redis database index, search index
|
|
289
|
+
prefixes, the Capybara server port, tmp and storage paths, cache namespaces, log file
|
|
290
|
+
paths, and any client library that read the variable at require time.
|
|
291
|
+
|
|
292
|
+
Boot-time checks ran once against the base configuration. For example, Rails'
|
|
293
|
+
`maintain_test_schema!` verified the schema of the unsuffixed database, not of the
|
|
294
|
+
per-child databases; make sure those exist and are migrated before the workers start.
|
|
295
|
+
|
|
296
|
+
A Rails recipe, in a support file loaded by `rails_helper.rb`. The gem itself has no
|
|
297
|
+
Rails dependency; this is host application code:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
# spec/support/hopper.rb
|
|
301
|
+
if defined?(RSpec::Hopper)
|
|
302
|
+
RSpec::Hopper.before_fork do
|
|
303
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
RSpec::Hopper.after_fork do |env_number|
|
|
307
|
+
# Re-read config/database.yml so its ERB sees the child's TEST_ENV_NUMBER,
|
|
308
|
+
# then reconnect. Repeat for each configured database if you use several.
|
|
309
|
+
ActiveRecord::Base.configurations = Rails.application.config.database_configuration
|
|
310
|
+
ActiveRecord::Base.establish_connection(Rails.env.to_sym)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The hook receives the child's `TEST_ENV_NUMBER` value (`""` or `"2"`..`"N"`); it is
|
|
316
|
+
also already set in `ENV`. Registering hooks in single-process or per-process mode is
|
|
317
|
+
harmless; they are only run in shared mode.
|
|
318
|
+
|
|
319
|
+
Add a guard in the host application so a missed consumer fails loudly before any test
|
|
320
|
+
touches the wrong database. A `before(:suite)` hook runs in every child before its
|
|
321
|
+
first unit, and a failure there makes the worker exit 2:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
RSpec.configure do |config|
|
|
325
|
+
config.before(:suite) do
|
|
326
|
+
suffix = ENV.fetch("TEST_ENV_NUMBER", "")
|
|
327
|
+
database = ActiveRecord::Base.connection.current_database
|
|
328
|
+
unless database.end_with?(suffix)
|
|
329
|
+
raise "worker #{ENV["TEST_ENV_NUMBER"].inspect} is connected to #{database}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Formatter output per child
|
|
336
|
+
|
|
337
|
+
The parent strips every `--format` and `--out` option from the RSpec arguments and
|
|
338
|
+
applies them in each child, substituting `%{n}` in `--out` paths with the child's
|
|
339
|
+
`TEST_ENV_NUMBER`:
|
|
340
|
+
|
|
341
|
+
```sh
|
|
342
|
+
rspec-hopper work --processes 4 --build "$B" --worker "$W" --redis "$R" \
|
|
343
|
+
--format RspecJunitFormatter --out "tmp/junit-%{n}.xml" -- spec
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
produces `tmp/junit-.xml`, `tmp/junit-2.xml`, `tmp/junit-3.xml` and
|
|
347
|
+
`tmp/junit-4.xml`. Without the placeholder every child writes the same file and the
|
|
348
|
+
last one wins.
|
|
349
|
+
|
|
350
|
+
A formatter that is given no `--out` of its own gets one: children write to
|
|
351
|
+
`tmp/rspec-hopper/<worker id>-<formatter>.<ext>`, and a run with no `--format` at all
|
|
352
|
+
gets a `progress` formatter pointed at that directory. Above one process, therefore,
|
|
353
|
+
nothing but hopper's own log lines reaches the console.
|
|
354
|
+
|
|
355
|
+
That is deliberate. Each child runs only its share of the build, so a per-process RSpec
|
|
356
|
+
summary describes a fragment, and a child that reserved nothing prints
|
|
357
|
+
`0 examples, 0 failures` for a build that may have failed. The closing word belongs to
|
|
358
|
+
`rspec-hopper report`, which is the only thing that sees every worker's results; run it
|
|
359
|
+
as a final CI step, or pass `--report-on-exit` to have the parent run it and adopt its
|
|
360
|
+
exit code. Console formatters such as `documentation` still interleave unreadably if
|
|
361
|
+
you point several children at the console yourself.
|
|
362
|
+
|
|
363
|
+
With `--processes 1` (the default) nothing is redirected: one worker owns the console
|
|
364
|
+
and prints its summary as plain `rspec` would.
|
|
365
|
+
|
|
366
|
+
## Retries, reclaims and per-unit counters
|
|
367
|
+
|
|
368
|
+
Each unit carries three counters, all visible in the attempt log:
|
|
369
|
+
|
|
370
|
+
| Counter | Starts at | Increments when | Compared against |
|
|
371
|
+
|---|---|---|---|
|
|
372
|
+
| `retry_index` | 0 | A completed attempt failed with only requeueable exceptions and the caps allowed a requeue. | `--max-requeues` |
|
|
373
|
+
| `reclaim_count` | 0 | Another worker took the unit from an owner that stopped heartbeating (dead, stopped, or past `--max-unit-duration`). Survives retries. | `--max-reclaims` |
|
|
374
|
+
| `ownership_generation` | 1 | Derived: `1 + retry_index + reclaim_count`. Identifies the logical owner across the stream entry being replaced on retry. | nothing |
|
|
375
|
+
|
|
376
|
+
Reclaiming never consumes retry budget, and a unit that is reclaimed after a worker
|
|
377
|
+
death and then passes is not flaky. A unit reclaimed more than `--max-reclaims` times
|
|
378
|
+
is finalized as failed with reason `reclaim_budget_exhausted`, so a file that crashes
|
|
379
|
+
every process that runs it cannot stall the build.
|
|
380
|
+
|
|
381
|
+
Hung tests: while a unit runs, a heartbeat thread renews its reservation every
|
|
382
|
+
`min(--timeout / 3, 30)` seconds. Once the unit has run for `--max-unit-duration`, the
|
|
383
|
+
thread records an `abandoned` event and stops renewing, so the unit becomes
|
|
384
|
+
reclaimable after a further `--timeout` seconds whether or not the process is alive.
|
|
385
|
+
After that same window the worker prints `Aborting worker: ./spec/foo_spec.rb exceeded
|
|
386
|
+
900s` and exits 4, because the hung test thread cannot be interrupted safely. A unit
|
|
387
|
+
that finishes inside the window finalizes normally and the `abandoned` event stands as
|
|
388
|
+
a warning.
|
|
389
|
+
|
|
390
|
+
A renewal that fails because Redis is briefly unreachable is retried every second
|
|
391
|
+
rather than ending the worker: the reservation stays this worker's until `--timeout`
|
|
392
|
+
passes without a renewal, so a blip shorter than that costs nothing. The worker prints
|
|
393
|
+
one line when renewals start failing and another when they recover. If the window does
|
|
394
|
+
pass, the unit is reclaimable by a sibling and the worker exits 2 rather than carrying
|
|
395
|
+
on with a unit it no longer owns.
|
|
396
|
+
|
|
397
|
+
## Attempt log
|
|
398
|
+
|
|
399
|
+
`hopper:{<build>}:attempts` is a Redis stream of JSON events, one per stream entry
|
|
400
|
+
(field `json`). `report` derives everything it says from it, and you can read it
|
|
401
|
+
yourself with `XRANGE`.
|
|
402
|
+
|
|
403
|
+
Every unit-scoped event carries `type`, `unit_id`, `worker_id`, `retry_index`,
|
|
404
|
+
`reclaim_count`, `ownership_generation` and `at_ms` (epoch milliseconds stamped by
|
|
405
|
+
Redis). Additional fields per type:
|
|
406
|
+
|
|
407
|
+
| Type | Meaning | Additional fields |
|
|
408
|
+
|---|---|---|
|
|
409
|
+
| `delivered` | A worker reserved the unit and completed delivery accounting. | `stream`, `entry_id`, `delivery_count` |
|
|
410
|
+
| `reclaimed` | A worker took the unit from an owner that stopped heartbeating. | `previous_worker_id` (null when it could not be determined), `stream`, `entry_id`, `delivery_count` |
|
|
411
|
+
| `requeued` | A failed attempt was requeued; `retry_index` is the new value. | `previous_retry_index`, `duration_ms`, `failure_summary`, `errors`, `stream`, `entry_id`, `new_entry_id` |
|
|
412
|
+
| `abandoned` | The unit exceeded `--max-unit-duration`; the owner stopped heartbeating. A warning, not a terminal state. | `elapsed_ms`, `stream`, `entry_id` |
|
|
413
|
+
| `finalized` | Terminal. | `outcome` (`passed` or `failed`), `duration_ms`, `reason` (`test_failure`, `retry_budget_exhausted` or `reclaim_budget_exhausted`; null when passed), `errors` (null when passed), `stream`, `entry_id`; `failure_summary` when the retry budget was exhausted; `previous_worker_id` and `delivery_count` when the reclaim budget was exhausted |
|
|
414
|
+
| `stale_rejected` | A worker's finalize or requeue was refused because ownership had moved; its result was discarded. | `operation`, `stream`, `entry_id`, `delivery_count` |
|
|
415
|
+
| `worker_error` | A non-example worker failure. Not unit-scoped. | `worker_id`, `phase` (`boot`, `init`, `reserve`, `execution`, `redis`, `formatter`), `unit_id` (null when unknown), `class`, `message`, `backtrace`, `at_ms` |
|
|
416
|
+
|
|
417
|
+
`errors` is an array of `{example_id, description, class, message, backtrace}`
|
|
418
|
+
objects. Messages are capped at 4 KiB and backtraces at 20 lines; if the array would
|
|
419
|
+
exceed 64 KiB, trailing entries are dropped and a final `{"truncated": n}` entry says
|
|
420
|
+
how many.
|
|
421
|
+
|
|
422
|
+
A unit is **failed** when its last `finalized` event has outcome `failed`, **flaky**
|
|
423
|
+
when it has at least one `requeued` event and its last `finalized` event is `passed`,
|
|
424
|
+
**never finalized** when the manifest lists it and no `finalized` event exists, and
|
|
425
|
+
**abandoned** when an `abandoned` event exists.
|
|
426
|
+
|
|
427
|
+
## Redis key layout and TTLs
|
|
428
|
+
|
|
429
|
+
All keys live under `hopper:{<build>}:`. The braces are a Redis Cluster hash tag so a
|
|
430
|
+
build's keys share one slot; Cluster itself is not supported in Phase 1.
|
|
431
|
+
|
|
432
|
+
| Key | Type | Contents | TTL |
|
|
433
|
+
|---|---|---|---|
|
|
434
|
+
| `units` | stream | One entry per unit (`id`, `type`), consumer group `workers`. | inactivity (`--ttl`) |
|
|
435
|
+
| `units:priority` | stream | Requeued units, read before `units`. Consumer group `workers`. | inactivity |
|
|
436
|
+
| `attempts` | stream | The attempt log. | inactivity |
|
|
437
|
+
| `meta` | hash | Manifest fields (`total_units`, `total_examples`, `file_counts`, `file_args`, `fingerprint`, `fingerprint_digests`, `seed`, `revision`, `load_errors`), `state` (`ready` or `init_failed`), `ready_at`, `finalized_count`, `requeued_units_count`, and the recorded `max_requeues`, `requeue_tolerance`, `max_reclaims`, `timeout` and `ttl`. | inactivity |
|
|
438
|
+
| `unit_state` | hash | Unit id to `{"retry_index", "reclaim_count", "entered_retry"}`. | inactivity |
|
|
439
|
+
| `workers` | hash | Worker id to `{"last_seen", "current_unit", "processed"}`. | inactivity |
|
|
440
|
+
| `leader` | string | Initialization lease, `<worker>:<nonce>`. | 60 s fixed, never renewed |
|
|
441
|
+
| `exists` | string | Tombstone, `"1"`. | `--tombstone-ttl`, never renewed |
|
|
442
|
+
|
|
443
|
+
The inactivity TTL (`--ttl`, default four hours) is set atomically with every write
|
|
444
|
+
that creates or could recreate a key, and renewed by every heartbeat, finalization,
|
|
445
|
+
requeue, reclaim and idle liveness update. A long build stays alive while anyone is
|
|
446
|
+
working and expires after the configured idle period. The tombstone outlives the
|
|
447
|
+
working keys so that `report` can tell "this build ran and its keys expired" (exit 3)
|
|
448
|
+
from "this build never existed" (exit 2), and so that a build id whose state vanished
|
|
449
|
+
is never reseeded. Reuse of a build id is therefore refused for the tombstone's
|
|
450
|
+
lifetime; generate a fresh id per CI run.
|
|
451
|
+
|
|
452
|
+
The gem never deletes a build's keys; they expire. Budget Redis memory for the number
|
|
453
|
+
of builds that start within one `--ttl` window.
|
|
454
|
+
|
|
455
|
+
## CI environment inference
|
|
456
|
+
|
|
457
|
+
When `--build` or `--worker` is omitted and `HOPPER_BUILD_ID` / `HOPPER_WORKER_ID` are
|
|
458
|
+
unset, the ids are read from the first detected vendor. This is the gem's only
|
|
459
|
+
CI-vendor awareness.
|
|
460
|
+
|
|
461
|
+
| Vendor | Detected by | Build id from | Worker id from |
|
|
462
|
+
|---|---|---|---|
|
|
463
|
+
| CircleCI | `CIRCLECI` | `CIRCLE_WORKFLOW_ID`, else `CIRCLE_BUILD_NUM` | `CIRCLE_NODE_INDEX` |
|
|
464
|
+
| Buildkite | `BUILDKITE` | `BUILDKITE_BUILD_ID` | `BUILDKITE_PARALLEL_JOB` |
|
|
465
|
+
| GitHub Actions | `GITHUB_ACTIONS` | `GITHUB_RUN_ID`-`GITHUB_RUN_ATTEMPT` | none; set `--worker` or `HOPPER_WORKER_ID` from your matrix |
|
|
466
|
+
| GitLab CI | `GITLAB_CI` | `CI_PIPELINE_ID` | `CI_NODE_INDEX` |
|
|
467
|
+
|
|
468
|
+
On GitHub Actions the run attempt is part of the build id so that a re-run of the
|
|
469
|
+
workflow does not collide with the tombstone of the previous attempt. On the other
|
|
470
|
+
vendors, re-running a job reuses the same build id; if the previous run's tombstone
|
|
471
|
+
still exists the workers exit 2 with "previously initialized", so pass a fresh
|
|
472
|
+
`--build` (for example with a retry counter appended) when re-running.
|
|
473
|
+
|
|
474
|
+
## Supported versions
|
|
475
|
+
|
|
476
|
+
- Ruby 3.2 or newer.
|
|
477
|
+
- `rspec-core` 3.12 up to, but not including, 4.0.
|
|
478
|
+
- `redis` (redis-rb) 5.x. 6.x is not yet supported.
|
|
479
|
+
- Redis server 6.2 or newer (`XAUTOCLAIM` and effects-replicated scripts), or Valkey.
|
|
480
|
+
Standalone or primary-replica deployments only; Redis Cluster is untested and
|
|
481
|
+
unsupported.
|
|
482
|
+
- Runtime dependencies are `rspec-core`, `redis` and `json` only.
|
|
483
|
+
|
|
484
|
+
## Development
|
|
485
|
+
|
|
486
|
+
```sh
|
|
487
|
+
bin/setup # bundle install
|
|
488
|
+
bin/test-redis # start a throwaway redis-server on port 6399 (bin/test-redis stop to stop it)
|
|
489
|
+
docker compose up -d redis # or Redis 7 in Docker on the same port
|
|
490
|
+
docker compose --profile valkey up -d valkey # or Valkey
|
|
491
|
+
docker compose --profile redis62 up -d redis62 # or Redis 6.2 (XAUTOCLAIM reply shape)
|
|
492
|
+
bundle exec rake # specs, then rubocop
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Specs that need Redis are tagged `:redis`, use `HOPPER_TEST_REDIS_URL` (default
|
|
496
|
+
`redis://127.0.0.1:6399/0`) and are skipped with a message when no server answers.
|
|
497
|
+
Integration specs under `spec/integration` spawn real `rspec-hopper` processes against
|
|
498
|
+
the fixture suites in `spec/fixtures/suites`. CI runs the suite on Ruby 3.2 through
|
|
499
|
+
4.0 against Redis 7, plus Redis 6.2 and Valkey on the newest Ruby.
|
|
500
|
+
|
|
501
|
+
### Releasing
|
|
502
|
+
|
|
503
|
+
Bump `RSpec::Hopper::VERSION`, move the changelog's `Unreleased` entries under the new
|
|
504
|
+
version, merge that to `main`, then run the **Release** workflow from `main`
|
|
505
|
+
(`gh workflow run release.yml`). It runs `bundle exec rake` against Redis, then
|
|
506
|
+
`rake release`, which tags `v<version>`, pushes the tag and pushes the gem to
|
|
507
|
+
RubyGems.org through [trusted publishing][], so there is no API key anywhere. The
|
|
508
|
+
workflow refuses to run from any branch but `main`, and `rake release` refuses a dirty
|
|
509
|
+
tree or a version that is already tagged.
|
|
510
|
+
|
|
511
|
+
[trusted publishing]: https://guides.rubygems.org/trusted-publishing/
|
|
512
|
+
|
|
513
|
+
## Phase 1 non-goals
|
|
514
|
+
|
|
515
|
+
- Example-level units or splitting slow files; a unit is a whole spec file.
|
|
516
|
+
- Retrying only the failed examples within a file; a requeue reruns the file.
|
|
517
|
+
- Timing-based ordering or scheduling.
|
|
518
|
+
- Metrics emission.
|
|
519
|
+
- Compatibility with ci-queue's flags.
|
|
520
|
+
- RSpec 4, redis-rb 6, Redis Cluster.
|
|
521
|
+
- Early exit for idle workers before the build completes.
|
|
522
|
+
- Build-global suite hooks.
|
|
523
|
+
- The rejected RSpec options.
|
|
524
|
+
|
|
525
|
+
## Prior art
|
|
526
|
+
|
|
527
|
+
Shopify's [ci-queue](https://github.com/Shopify/ci-queue) established the idea of
|
|
528
|
+
feeding RSpec and Minitest workers from a shared Redis queue with requeues for flaky
|
|
529
|
+
tests, and much of what rspec-hopper does is a response to running it at scale: key
|
|
530
|
+
leaks under `volatile-lru`, empty-queue false greens, method-prepend collisions with
|
|
531
|
+
other instrumentation, per-worker exit codes, and `Marshal` over the wire. rspec-hopper
|
|
532
|
+
is a from-scratch design around those failure modes rather than a fork, and shares
|
|
533
|
+
neither code nor a compatible command line.
|
|
534
|
+
|
|
535
|
+
## License
|
|
536
|
+
|
|
537
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|