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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. 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).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]