rspec-hopper 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +160 -23
- data/docs/DESIGN.md +43 -16
- data/lib/rspec/hopper/cli/work/parser.rb +9 -0
- data/lib/rspec/hopper/config.rb +4 -2
- data/lib/rspec/hopper/example_reset.rb +7 -3
- data/lib/rspec/hopper/example_subset.rb +56 -0
- data/lib/rspec/hopper/fingerprint.rb +8 -5
- data/lib/rspec/hopper/manifest.rb +33 -11
- data/lib/rspec/hopper/queue/redis_streams.rb +1 -1
- data/lib/rspec/hopper/report.rb +3 -1
- data/lib/rspec/hopper/unit.rb +16 -3
- data/lib/rspec/hopper/version.rb +1 -1
- data/lib/rspec/hopper/worker/suite.rb +100 -10
- data/lib/rspec/hopper/worker.rb +5 -5
- data/lib/rspec/hopper.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38c293f2c7a326a8b7ee5cd6883a72f392b55855eff1ad33a4863453dd6bc98a
|
|
4
|
+
data.tar.gz: a104e430ab3d22c8c8494a561fd57175039dadf3aed0a4b6f3f709f0e9e9a901
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77aaec88287312fa7460729c951a4bb0e2c976fbaff9ace8dd2142d3a9dace14ca72218397d7cbc7447b7bdd3a2269a29fdc8ed8bf5d635617806f3fe8579f6d
|
|
7
|
+
data.tar.gz: f2b139cacf77e4b95cebd2b01f74948999360c32cfad10b4c70bb295f3b3b3dfed427bc2c8ff0ad82b851e7146c9f3d898be497d9a30e011d5349ba14abe47e5
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-09-13
|
|
6
|
+
|
|
7
|
+
- A Rails example application under `examples/rails-app`, with its own bundle, that CI
|
|
8
|
+
drives through three shared-boot children in both unit modes: per-process SQLite
|
|
9
|
+
databases chosen by `TEST_ENV_NUMBER`, the README's fork hooks and database guard,
|
|
10
|
+
transactional tests, `before(:all)` records and a request spec.
|
|
11
|
+
- `--unit example`: one work unit per selected example instead of per spec file.
|
|
12
|
+
Examples are queued in the order RSpec would run them under the build seed, run
|
|
13
|
+
through their file's group with the selection narrowed to the one example (context
|
|
14
|
+
hooks fire per unit), and a requeue reruns only the failed example. The unit type is
|
|
15
|
+
part of the suite fingerprint, is recorded in the manifest and the report summary
|
|
16
|
+
(`unit_type`), and `--failed-out` then lists example ids. `--unit file` stays the
|
|
17
|
+
default and its Redis layout is unchanged apart from the new `unit_type` meta field.
|
|
18
|
+
- `ExampleSubset`, the second sanctioned touch of rspec-core internals, guarded by a
|
|
19
|
+
contract spec like `ExampleReset`.
|
|
20
|
+
- Warn at boot when a gem wraps `RSpec::Core::Runner#run_specs`, which the worker's
|
|
21
|
+
runner replaces: that wrapper never runs, and for datadog-ci it is where the test
|
|
22
|
+
session and module are started. Documents starting such lifecycles in suite hooks.
|
|
23
|
+
- Point the gem's `documentation_uri` at the design document.
|
|
24
|
+
|
|
5
25
|
## [0.1.0] - 2026-09-11
|
|
6
26
|
|
|
7
27
|
- Initial Phase 1 implementation: file-level work units distributed through
|
data/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# rspec-hopper
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/rb/rspec-hopper)
|
|
4
|
+
[](https://dl.circleci.com/status-badge/redirect/gh/prsimp/rspec-hopper/tree/main)
|
|
5
|
+
|
|
3
6
|
rspec-hopper distributes an RSpec suite across many CI workers through a shared
|
|
4
7
|
Redis, requeues flaky work, reclaims work from workers that die, and produces one
|
|
5
8
|
authoritative pass/fail verdict for the whole build.
|
|
6
9
|
|
|
7
|
-
A hopper feeds a machine continuously: workers pull spec files
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
own share of the work.
|
|
10
|
+
A hopper feeds a machine continuously: workers pull spec files (or, with
|
|
11
|
+
`--unit example`, single examples) from a shared queue as fast as they finish them,
|
|
12
|
+
and requeued work drops back in ahead of untouched work. There is no up-front
|
|
13
|
+
partitioning, so a slow file or a slow machine only delays its own share of the work.
|
|
11
14
|
|
|
12
15
|
## Problems it solves
|
|
13
16
|
|
|
@@ -21,8 +24,10 @@ exit with its own status" approach as it exists in other tools:
|
|
|
21
24
|
run". The report tells them apart and only passes when a published manifest exists
|
|
22
25
|
and every unit in it finalized as passed.
|
|
23
26
|
- **Fighting other gems over RSpec internals.** Nothing is prepended onto
|
|
24
|
-
`RSpec::Core::Example#run`, `#start` or `#finish`, so
|
|
25
|
-
(datadog-ci, rspec-retry and others)
|
|
27
|
+
`RSpec::Core::Example#run`, `#start` or `#finish`, so the per-example half of
|
|
28
|
+
instrumentation gems (datadog-ci, rspec-retry and others) keeps working. Anything
|
|
29
|
+
those gems wrap around `RSpec::Core::Runner#run_specs` is a different matter — see
|
|
30
|
+
[Instrumentation that wraps the runner](#instrumentation-that-wraps-the-runner).
|
|
26
31
|
- **Ambiguous exit codes.** Workers exit 0 on completion regardless of test results;
|
|
27
32
|
`rspec-hopper report` is the single place a build's verdict comes from.
|
|
28
33
|
- **`Marshal.load` from a shared Redis.** Everything stored is JSON.
|
|
@@ -70,8 +75,13 @@ bundle exec rspec-hopper report \
|
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
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
|
|
74
|
-
|
|
78
|
+
`--failed-out` file holds one unit id per line (failed and never-finalized units): a
|
|
79
|
+
spec file, or under `--unit example` an example id such as `./spec/foo_spec.rb[1:2]`.
|
|
80
|
+
Either form is an argument `rspec` accepts, so this reruns them locally:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
xargs bundle exec rspec < tmp/hopper-failed.txt
|
|
84
|
+
```
|
|
75
85
|
|
|
76
86
|
`--build`, `--worker` and `--redis` can be omitted when `HOPPER_BUILD_ID`,
|
|
77
87
|
`HOPPER_WORKER_ID` and `HOPPER_REDIS_URL` (or `REDIS_URL`) are set, and the build and
|
|
@@ -84,13 +94,15 @@ worker, `<hostname>-<pid>` is used.
|
|
|
84
94
|
1. Every worker boots the application and loads the spec files with RSpec, exactly as
|
|
85
95
|
`rspec` would, applying `.rspec`, `~/.rspec`, `SPEC_OPTS` and the command line.
|
|
86
96
|
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
|
|
97
|
+
file that has at least one selected example (or per selected example, see
|
|
98
|
+
[Work units](#work-units-files-or-examples)), plus a manifest (unit and example
|
|
88
99
|
counts, the file arguments, the suite fingerprint, the seed). Every other worker
|
|
89
100
|
waits for the manifest, then checks that its own suite fingerprint matches. A
|
|
90
101
|
worker that does not match exits 2 and names the inputs that differ.
|
|
91
102
|
3. Workers loop: reclaim a unit whose owner stopped heartbeating, or reserve the next
|
|
92
|
-
unit (requeued units first), run all of
|
|
93
|
-
|
|
103
|
+
unit (requeued units first), run it through `ExampleGroup.run` (all of a file's
|
|
104
|
+
top-level groups, or one example narrowed within its group), then finalize the unit
|
|
105
|
+
as passed or failed, or requeue it.
|
|
94
106
|
4. Formatter output is buffered per attempt and replayed only for the final attempt,
|
|
95
107
|
so JUnit and JSON files contain each example exactly once. Flakiness is recorded in
|
|
96
108
|
the attempt log, not in formatter output.
|
|
@@ -124,6 +136,37 @@ These hold regardless of worker crashes, hung tests, or Redis trouble:
|
|
|
124
136
|
- Redis, application or worker death and hung tests may delay completion; they cannot
|
|
125
137
|
silently change the verdict or prevent completion indefinitely.
|
|
126
138
|
|
|
139
|
+
## Work units: files or examples
|
|
140
|
+
|
|
141
|
+
`--unit file` (the default) makes each spec file one unit. `--unit example` makes
|
|
142
|
+
each selected example one unit, identified by its RSpec id
|
|
143
|
+
(`./spec/models/foo_spec.rb[1:2:1]`). The choice belongs to the build: the unit type
|
|
144
|
+
is part of the suite fingerprint, so a worker started with the other value exits 2
|
|
145
|
+
naming `unit_type` as the differing input.
|
|
146
|
+
|
|
147
|
+
Example units buy two things. Scheduling is finer, so one slow file no longer holds
|
|
148
|
+
a worker while the others sit idle, and a requeue reruns only the example that
|
|
149
|
+
failed rather than its whole file. What they cost:
|
|
150
|
+
|
|
151
|
+
- **Context hooks run per example.** An example unit runs through its file's group
|
|
152
|
+
with the selection narrowed to that one example, so `before(:context)` and
|
|
153
|
+
`after(:context)` hooks on the path to the example fire once per unit, not once
|
|
154
|
+
per file. Sibling contexts that contain no selected example are skipped entirely.
|
|
155
|
+
A file whose `before(:all)` is expensive is a reason to stay with file units.
|
|
156
|
+
- **Documentation-style formatters repeat group headers.** Each unit replays its own
|
|
157
|
+
`example_group_started` notifications, so the documentation formatter prints the
|
|
158
|
+
group description before every example. JUnit and JSON output are unaffected:
|
|
159
|
+
every example still appears exactly once.
|
|
160
|
+
- **The manifest is larger.** It lists every unit id, about 40 bytes per example,
|
|
161
|
+
and the queue holds one entry per example. Redis handles tens of thousands of
|
|
162
|
+
entries comfortably; a suite of several hundred thousand examples should measure
|
|
163
|
+
before switching.
|
|
164
|
+
|
|
165
|
+
Ordering is preserved. The publishing worker queues the examples in the order RSpec
|
|
166
|
+
would have run them under the build seed, so `--order rand` still randomizes the
|
|
167
|
+
sequence and `--seed` still reproduces it. Reclaims and requeues of individual
|
|
168
|
+
examples do not change the order of the remaining queue.
|
|
169
|
+
|
|
127
170
|
## Suite hooks run once per worker process
|
|
128
171
|
|
|
129
172
|
`before(:suite)` and `after(:suite)` run once in **every worker process**, not once per
|
|
@@ -134,6 +177,64 @@ workers start.
|
|
|
134
177
|
|
|
135
178
|
A `before(:suite)` hook that raises makes that worker exit 2 without running any unit.
|
|
136
179
|
|
|
180
|
+
### Instrumentation that wraps the runner
|
|
181
|
+
|
|
182
|
+
The no-prepend promise covers `RSpec::Core::Example`. It does not extend to
|
|
183
|
+
`RSpec::Core::Runner#run_specs`: the worker runs a `Runner` subclass that defines
|
|
184
|
+
`run_specs` itself, driving the queue instead of `world.ordered_example_groups`, and a
|
|
185
|
+
module prepended onto the superclass cannot intercept a method the subclass defines. Any
|
|
186
|
+
gem whose session or reporting lifecycle lives in a `run_specs` wrapper will not have
|
|
187
|
+
that wrapper run.
|
|
188
|
+
|
|
189
|
+
datadog-ci is the case to know about. Its per-example patches still produce test spans,
|
|
190
|
+
but `start_test_session` and `start_test_module` live in its `run_specs` wrapper, so
|
|
191
|
+
without them every span is emitted with no session or module to belong to and Datadog
|
|
192
|
+
drops the lot — `Event with type test(name=rspec.test) is invalid: test_session_id is
|
|
193
|
+
required`. A worker prints a warning at boot when it finds such a wrapper, because the
|
|
194
|
+
alternative is a green build reporting nothing.
|
|
195
|
+
|
|
196
|
+
Start and finish that lifecycle in suite hooks instead. They run once per worker
|
|
197
|
+
process, which is the same granularity each `parallel_tests` process had:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# spec/support/datadog.rb
|
|
201
|
+
RSpec.configure do |config|
|
|
202
|
+
config.before(:suite) do
|
|
203
|
+
Datadog::CI.start_test_session(service: "my-suite")
|
|
204
|
+
Datadog::CI.start_test_module("rspec")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
config.after(:suite) do
|
|
208
|
+
# `with_suite_hooks` runs this from an `ensure`, so it also fires while the
|
|
209
|
+
# worker is unwinding from an infrastructure failure — Redis unreachable,
|
|
210
|
+
# `CORRUPT`, `meta` gone. No example failed in that case, so check for the
|
|
211
|
+
# exception in flight or the session finishes green on a worker that died.
|
|
212
|
+
failed = !$!.nil? || RSpec.configuration.reporter.failed_examples.any?
|
|
213
|
+
|
|
214
|
+
test_module = Datadog::CI.active_test_module
|
|
215
|
+
test_session = Datadog::CI.active_test_session
|
|
216
|
+
failed ? test_module&.failed! : test_module&.passed!
|
|
217
|
+
failed ? test_session&.failed! : test_session&.passed!
|
|
218
|
+
test_module&.finish
|
|
219
|
+
test_session&.finish
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
By the time `after(:suite)` runs, the reporter holds every example this worker
|
|
225
|
+
finalized: each attempt replays its buffer as the unit finalizes, and the whole unit
|
|
226
|
+
loop sits inside the suite hooks.
|
|
227
|
+
|
|
228
|
+
Three things to keep in mind. Each worker process opens its own session, so a build of
|
|
229
|
+
ten workers reports ten sessions — Datadog's own guidance for `parallel_tests`-style
|
|
230
|
+
parallelism — and which files land in which session changes run to run, because the
|
|
231
|
+
queue hands out work dynamically. `force_test_level_visibility` must stay off: it
|
|
232
|
+
disables the suite-level visibility these calls depend on, and they silently return
|
|
233
|
+
`nil` with it on. And a worker that aborts a hung unit exits through `exit!`, which runs
|
|
234
|
+
no `ensure` and therefore no `after(:suite)`: that worker's session is never finished,
|
|
235
|
+
and its tests are lost to Datadog even though `rspec-hopper report` still counts the
|
|
236
|
+
unit.
|
|
237
|
+
|
|
137
238
|
## CLI reference
|
|
138
239
|
|
|
139
240
|
### `rspec-hopper work`
|
|
@@ -148,8 +249,9 @@ rspec-hopper work --build ID --worker WID --redis URL [options] [rspec args...]
|
|
|
148
249
|
| `--worker WID` | `$HOPPER_WORKER_ID`, CI inference, then `<hostname>-<pid>` | This worker's id, unique within the build. |
|
|
149
250
|
| `--redis URL` | `$HOPPER_REDIS_URL`, then `$REDIS_URL` | Redis URL. |
|
|
150
251
|
| `--revision SHA` | none | String mixed into the suite fingerprint, for example the commit being tested. |
|
|
252
|
+
| `--unit TYPE` | `file` | What one queue entry is: `file` or `example`. See [Work units](#work-units-files-or-examples). |
|
|
151
253
|
| `--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
|
|
254
|
+
| `--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 unit. |
|
|
153
255
|
| `--max-requeues N` | 0 | Maximum number of retries of any one unit. |
|
|
154
256
|
| `--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
257
|
| `--max-reclaims N` | 3 | Reclaims from dead or abandoning owners before a unit is finalized as failed. |
|
|
@@ -164,9 +266,9 @@ Either retry limit at zero disables requeues; the defaults run every file once.
|
|
|
164
266
|
retry, reclaim and timeout values of the worker that publishes the build are recorded
|
|
165
267
|
in the build and enforced for every worker, so give all workers the same flags.
|
|
166
268
|
|
|
167
|
-
Requeue eligibility: a failed attempt is requeued only if every failure in the
|
|
269
|
+
Requeue eligibility: a failed attempt is requeued only if every failure in the unit is
|
|
168
270
|
requeueable, meaning anything except `SystemExit`, `Interrupt`, `SignalException` and
|
|
169
|
-
`NoMemoryError`. One of those anywhere in the
|
|
271
|
+
`NoMemoryError`. One of those anywhere in the unit makes the attempt final. When a
|
|
170
272
|
retry would exceed `--max-requeues` or the tolerance, the failure is final with reason
|
|
171
273
|
`retry_budget_exhausted`. On a requeue the worker prints one line and emits nothing to
|
|
172
274
|
formatters:
|
|
@@ -182,7 +284,8 @@ wherever they come from (`.rspec`, `~/.rspec`, `SPEC_OPTS` or the command line):
|
|
|
182
284
|
state or with distributed execution.
|
|
183
285
|
|
|
184
286
|
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
|
|
287
|
+
random) and adopted by every worker, so within-file ordering is identical everywhere,
|
|
288
|
+
and under `--unit example` the queue itself is in that order.
|
|
186
289
|
|
|
187
290
|
### `rspec-hopper report`
|
|
188
291
|
|
|
@@ -198,7 +301,7 @@ rspec-hopper report --build ID --redis URL [options]
|
|
|
198
301
|
| `--init-timeout S` | 300 | Wait this long for the manifest or tombstone to appear before exiting 2. |
|
|
199
302
|
| `--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
303
|
| `--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. |
|
|
304
|
+
| `--failed-out PATH` | none | Write failed and never-finalized unit ids to PATH, one per line (file paths or example ids, depending on the build's `--unit`). |
|
|
202
305
|
| `--fail-on-empty` | on | Zero selected examples is a failure. |
|
|
203
306
|
| `--allow-empty` | off | Zero selected examples may pass. Cannot be combined with a positive `--min-examples`. |
|
|
204
307
|
| `--min-examples N` | 0 | Fail unless at least N examples were selected, guarding against a filter that selects almost nothing. |
|
|
@@ -210,7 +313,7 @@ count as worker activity, so a file slower than `--inactive-timeout` does not tr
|
|
|
210
313
|
|
|
211
314
|
The JSON summary has these keys: `build_id`, `state`, `verdict` (`passed`, `failed`,
|
|
212
315
|
`incomplete`, `init_failed`, `missing`, `expired`, `unreachable`), `exit_code`,
|
|
213
|
-
`message`, `total_units`, `total_examples`, `finalized_count`, `failed` (unit id,
|
|
316
|
+
`message`, `total_units`, `unit_type`, `total_examples`, `finalized_count`, `failed` (unit id,
|
|
214
317
|
reason, worker id, errors), `flaky`, `never_finalized` (unit id, last worker id),
|
|
215
318
|
`abandoned`, `retry_counts`, `reclaim_counts`, `worker_errors`, `stale_rejections`,
|
|
216
319
|
`workers`, `load_errors`, `file_args`, `seed`, `fingerprint`, `revision`.
|
|
@@ -312,6 +415,10 @@ if defined?(RSpec::Hopper)
|
|
|
312
415
|
end
|
|
313
416
|
```
|
|
314
417
|
|
|
418
|
+
A runnable version of this recipe, with the guard below, is the Rails application under
|
|
419
|
+
[`examples/rails-app`](examples/rails-app), which the gem's own CI drives through three
|
|
420
|
+
shared-boot children.
|
|
421
|
+
|
|
315
422
|
The hook receives the child's `TEST_ENV_NUMBER` value (`""` or `"2"`..`"N"`); it is
|
|
316
423
|
also already set in `ENV`. Registering hooks in single-process or per-process mode is
|
|
317
424
|
harmless; they are only run in shared mode.
|
|
@@ -324,7 +431,7 @@ first unit, and a failure there makes the worker exit 2:
|
|
|
324
431
|
RSpec.configure do |config|
|
|
325
432
|
config.before(:suite) do
|
|
326
433
|
suffix = ENV.fetch("TEST_ENV_NUMBER", "")
|
|
327
|
-
database = ActiveRecord::Base.
|
|
434
|
+
database = ActiveRecord::Base.connection_db_config.database.to_s
|
|
328
435
|
unless database.end_with?(suffix)
|
|
329
436
|
raise "worker #{ENV["TEST_ENV_NUMBER"].inspect} is connected to #{database}"
|
|
330
437
|
end
|
|
@@ -363,6 +470,30 @@ you point several children at the console yourself.
|
|
|
363
470
|
With `--processes 1` (the default) nothing is redirected: one worker owns the console
|
|
364
471
|
and prints its summary as plain `rspec` would.
|
|
365
472
|
|
|
473
|
+
#### A single-process worker can look hung in CI
|
|
474
|
+
|
|
475
|
+
RSpec's default `progress` formatter writes a bare dot per example with no newline.
|
|
476
|
+
Nothing in the gem buffers — rspec-core sets `sync` on the output stream, and hopper's
|
|
477
|
+
own lines (`initialized build ...`, `Retrying ...`, abort warnings) end in newlines — but
|
|
478
|
+
a log viewer that renders whole lines has nothing to show between them. A worker that
|
|
479
|
+
spends ten minutes on a queue of slow files therefore looks like it has stopped, and a
|
|
480
|
+
CI no-output timeout has nothing to reset it with.
|
|
481
|
+
|
|
482
|
+
If that matters on your CI, give the console a formatter that emits lines and send the
|
|
483
|
+
machine-readable one to a file:
|
|
484
|
+
|
|
485
|
+
```sh
|
|
486
|
+
rspec-hopper work --build "$B" --worker "$W" --redis "$R" \
|
|
487
|
+
--format documentation \
|
|
488
|
+
--format RspecJunitFormatter --out "tmp/junit-$W.xml" -- spec
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
This is the `--processes 1` case, so name the file yourself — `%{n}` is substituted only
|
|
492
|
+
for forked children, and a single-process run passes the arguments to RSpec untouched.
|
|
493
|
+
Above one process the console formatter is redirected to a file anyway (see above), so
|
|
494
|
+
only hopper's own lines reach the console, and the build's progress is better watched
|
|
495
|
+
through `rspec-hopper report`.
|
|
496
|
+
|
|
366
497
|
## Retries, reclaims and per-unit counters
|
|
367
498
|
|
|
368
499
|
Each unit carries three counters, all visible in the attempt log:
|
|
@@ -495,8 +626,14 @@ bundle exec rake # specs, then rubocop
|
|
|
495
626
|
Specs that need Redis are tagged `:redis`, use `HOPPER_TEST_REDIS_URL` (default
|
|
496
627
|
`redis://127.0.0.1:6399/0`) and are skipped with a message when no server answers.
|
|
497
628
|
Integration specs under `spec/integration` spawn real `rspec-hopper` processes against
|
|
498
|
-
the fixture suites in `spec/fixtures/suites
|
|
499
|
-
|
|
629
|
+
the fixture suites in `spec/fixtures/suites`, and `spec/integration/rails_example_spec.rb`
|
|
630
|
+
against the Rails application in `examples/rails-app`, which has its own bundle: run
|
|
631
|
+
`bundle install` there once, or the spec skips itself. CircleCI runs the suite on Ruby 3.2
|
|
632
|
+
through 4.0 against Redis 7, plus Redis 6.2 and Valkey on the newest Ruby, the Rails
|
|
633
|
+
example once, and rubocop once (`.circleci/config.yml`). Each spec job writes a JUnit file that CircleCI stores,
|
|
634
|
+
so slow and failing examples are visible per job rather than only in the log. Releases stay on GitHub Actions, because RubyGems
|
|
635
|
+
trusted publishing authenticates that workflow's OIDC token; the release job runs the
|
|
636
|
+
same suite before it publishes.
|
|
500
637
|
|
|
501
638
|
### Releasing
|
|
502
639
|
|
|
@@ -510,10 +647,10 @@ tree or a version that is already tagged.
|
|
|
510
647
|
|
|
511
648
|
[trusted publishing]: https://guides.rubygems.org/trusted-publishing/
|
|
512
649
|
|
|
513
|
-
##
|
|
650
|
+
## Non-goals
|
|
514
651
|
|
|
515
|
-
-
|
|
516
|
-
|
|
652
|
+
- Splitting a file into anything other than whole examples; there is no partial retry
|
|
653
|
+
of a file unit, a requeue reruns the whole unit.
|
|
517
654
|
- Timing-based ordering or scheduling.
|
|
518
655
|
- Metrics emission.
|
|
519
656
|
- Compatibility with ci-queue's flags.
|
data/docs/DESIGN.md
CHANGED
|
@@ -23,6 +23,7 @@ lib/rspec/hopper/attempt_log.rb Event + queries
|
|
|
23
23
|
lib/rspec/hopper/manifest.rb
|
|
24
24
|
lib/rspec/hopper/fingerprint.rb
|
|
25
25
|
lib/rspec/hopper/example_reset.rb
|
|
26
|
+
lib/rspec/hopper/example_subset.rb narrows a group to one example around ExampleGroup.run
|
|
26
27
|
lib/rspec/hopper/worker.rb the RSpec adapter: loop, init/election, completion
|
|
27
28
|
lib/rspec/hopper/worker/suite.rb loads RSpec, discovers units, option checks, seed adoption
|
|
28
29
|
lib/rspec/hopper/worker/buffering_reporter.rb
|
|
@@ -39,9 +40,10 @@ exe/rspec-hopper
|
|
|
39
40
|
spec/spec_helper.rb loads spec/support/**/*.rb
|
|
40
41
|
spec/support/redis_helper.rb TEST redis url, flush helper, key/TTL scan
|
|
41
42
|
spec/rspec/hopper/** unit specs, mirror lib layout
|
|
42
|
-
spec/contract/** example_reset, prepend coexistence
|
|
43
|
+
spec/contract/** example_reset, example_subset, prepend coexistence
|
|
43
44
|
spec/fixtures/suites/<name>/ fixture suites (each has .rspec, spec/, optional spec_helper)
|
|
44
45
|
spec/integration/** spawn real `exe/rspec-hopper work` processes
|
|
46
|
+
examples/rails-app/ minimal Rails app with its own bundle; driven by spec/integration/rails_example_spec.rb
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
Namespace is `RSpec::Hopper` (capital S). Runtime deps: rspec-core, redis, json only.
|
|
@@ -237,10 +239,13 @@ A unit reclaimed and then passing with no `requeued` event is not flaky.
|
|
|
237
239
|
|
|
238
240
|
## Manifest
|
|
239
241
|
|
|
240
|
-
`Manifest = Data.define(:total_units, :total_examples, :
|
|
241
|
-
:fingerprint, :fingerprint_digests, :seed, :ready_at, :revision, :load_errors)` —
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
`Manifest = Data.define(:total_units, :total_examples, :unit_type, :unit_ids, :file_counts,
|
|
243
|
+
:file_args, :fingerprint, :fingerprint_digests, :seed, :ready_at, :revision, :load_errors)` —
|
|
244
|
+
`unit_type` is `file` or `example`. For file units `unit_ids` defaults to the keys of
|
|
245
|
+
`file_counts` in order and is not written to `meta`; for example units it is given
|
|
246
|
+
explicitly (the example ids in queue order) and written as the JSON field `unit_ids`.
|
|
247
|
+
`total_units == unit_ids.size` always; `file_counts` (selected examples per file) is
|
|
248
|
+
kept for both types.
|
|
244
249
|
`#to_meta` -> Hash of String->String for HSET (JSON-encoding the nested fields);
|
|
245
250
|
`Manifest.from_meta(hash)` inverse (ignores the extra runtime fields). `ready_at` is
|
|
246
251
|
epoch ms set by Redis; before publication it's nil.
|
|
@@ -261,19 +266,26 @@ children). Sequence:
|
|
|
261
266
|
`configuration.load_spec_files`; `RSpec.world.wants_to_quit || rspec_is_quitting`
|
|
262
267
|
with captured messages => `load_errors`. Any other exception during boot is phase
|
|
263
268
|
`boot` -> exit 2.
|
|
264
|
-
2. Units: `RSpec.world.ordered_example_groups` (top-level, in configured order); a
|
|
265
|
-
is every distinct `group.metadata[:file_path]` whose `descendant_filtered_examples`
|
|
269
|
+
2. Units: `RSpec.world.ordered_example_groups` (top-level, in configured order); a file
|
|
270
|
+
unit is every distinct `group.metadata[:file_path]` whose `descendant_filtered_examples`
|
|
266
271
|
is non-empty; file counts are `descendant_filtered_examples.size` summed per file;
|
|
267
|
-
unit id is `metadata[:file_path]` verbatim (`./spec/...`).
|
|
272
|
+
unit id is `metadata[:file_path]` verbatim (`./spec/...`). With
|
|
273
|
+
`config.unit_type == "example"` a unit is every selected example, id `example.id`
|
|
274
|
+
(`./spec/foo_spec.rb[1:2:1]`), in the order `ExampleGroup.run` would execute them:
|
|
275
|
+
per top-level group, `ordering_strategy.order(filtered_examples)` then
|
|
276
|
+
`ordering_strategy.order(children)` recursively, under this worker's seed (which is
|
|
277
|
+
the seed the initializer publishes). Example ids for the
|
|
268
278
|
fingerprint are `example.id` over `RSpec.world.all_examples` filtered to the selected
|
|
269
279
|
set (`group.descendant_filtered_examples` across all top-level groups).
|
|
270
|
-
3. Fingerprint (`Fingerprint.compute(configuration:, options:, revision:)`):
|
|
271
|
-
a canonical JSON of `{file_args: sorted, filter: inclusion+exclusion rules as
|
|
280
|
+
3. Fingerprint (`Fingerprint.compute(configuration:, options:, revision:, unit_type:)`):
|
|
281
|
+
SHA256 over a canonical JSON of `{file_args: sorted, filter: inclusion+exclusion rules as
|
|
272
282
|
strings, pattern:, exclude_pattern:, order: configuration.ordering_registry ... name
|
|
273
283
|
(`RSpec.configuration.ordering_manager` exposes `seed_used?`/`order`; derive the
|
|
274
284
|
strategy name from `configuration.ordering_manager.instance_variable_get`? NO —
|
|
275
285
|
use the merged option `options.options[:order]` string minus any `:seed` suffix, or
|
|
276
|
-
`"defined"` when absent), example_ids: sorted, revision:}`.
|
|
286
|
+
`"defined"` when absent), example_ids: sorted, revision:, unit_type:}`. Including the
|
|
287
|
+
unit type is what stops a `--unit file` worker joining a `--unit example` build; the
|
|
288
|
+
mismatch message names `unit_type`. Also returns the
|
|
277
289
|
`inputs` hash so a mismatch message can diff them (`Fingerprint::Mismatch#explain`).
|
|
278
290
|
4. Election/join per product spec; `config.init_timeout` bounds it. After `ready`:
|
|
279
291
|
compare fingerprint; `RSpec.configuration.seed = manifest.seed`.
|
|
@@ -288,9 +300,11 @@ children). Sequence:
|
|
|
288
300
|
supervised, continue. With a reservation: `ExampleReset.reset(groups)` if
|
|
289
301
|
`reservation.ownership_generation > 1` OR the process has run these groups before
|
|
290
302
|
(simplest: always reset before running; the reset is idempotent on fresh examples —
|
|
291
|
-
do it always), start `Heartbeat`, run each top-level
|
|
292
|
-
`group.run(buffering_reporter)` in
|
|
293
|
-
|
|
303
|
+
do it always), start `Heartbeat`, run the unit via `Suite#run_unit`: each top-level
|
|
304
|
+
group of a file unit through `group.run(buffering_reporter)` in
|
|
305
|
+
`ordered_example_groups` order, or for an example unit its one top-level group inside
|
|
306
|
+
`ExampleSubset.scoped(group, [example])`; stop heartbeat, then
|
|
307
|
+
`RequeuePolicy.decide(examples)`.
|
|
294
308
|
7. `RequeuePolicy`: collect `execution_result` of the unit's selected examples; failed
|
|
295
309
|
examples' `exception` (and for `RSpec::Core::MultipleExceptionError`, `all_exceptions`)
|
|
296
310
|
must all be requeueable (`not SystemExit/Interrupt/SignalException/NoMemoryError`);
|
|
@@ -337,6 +351,18 @@ ivar list after a run under bare rspec-core 3.13.6:
|
|
|
337
351
|
`[:@clock, :@example_block, :@example_group_class, :@example_group_instance,
|
|
338
352
|
:@exception, :@id, :@metadata, :@reporter]`.
|
|
339
353
|
|
|
354
|
+
`ExampleSubset.scoped(root, examples) { root.run(reporter) }` — the second sanctioned
|
|
355
|
+
touch of rspec-core internals (Phase 2). `ExampleGroup.run` takes what to run from
|
|
356
|
+
`RSpec.world.filtered_examples[group]` and decides whether to run a group's context
|
|
357
|
+
hooks from the memoized `@descendant_filtered_examples`. For every group in
|
|
358
|
+
`root.descendants` the module saves both, replaces the world entry with the
|
|
359
|
+
intersection with `examples` and clears the memo, yields, and restores both in an
|
|
360
|
+
`ensure`. Nothing else; no method resolution changes. The contract spec asserts the
|
|
361
|
+
hook behaviour (only hooks on the path to the example fire, siblings are skipped),
|
|
362
|
+
restoration after an exception, that `ExampleGroup.filtered_examples` is the very
|
|
363
|
+
object in the world hash, and pins the class ivar list of a run group under bare
|
|
364
|
+
rspec-core 3.13.6 (`ExampleSubset::EXPECTED_GROUP_IVARS`).
|
|
365
|
+
|
|
340
366
|
## Report
|
|
341
367
|
|
|
342
368
|
`Report.new(config:, queue:, clock:, sleeper:)`, `#run(out:)` -> exit code; `#summary`
|
|
@@ -360,13 +386,14 @@ never_finalized).
|
|
|
360
386
|
## Config
|
|
361
387
|
|
|
362
388
|
```ruby
|
|
363
|
-
WorkConfig = Data.define(:build_id, :worker_id, :redis_url, :timeout, :max_unit_duration,
|
|
389
|
+
WorkConfig = Data.define(:build_id, :worker_id, :redis_url, :unit_type, :timeout, :max_unit_duration,
|
|
364
390
|
:max_requeues, :requeue_tolerance, :max_reclaims, :processes, :boot, :report_on_exit,
|
|
365
391
|
:ttl, :tombstone_ttl, :init_timeout, :revision, :rspec_args, :report_args, :supervised)
|
|
366
392
|
ReportConfig = Data.define(:build_id, :redis_url, :timeout, :init_timeout,
|
|
367
393
|
:inactive_timeout, :summary_out, :failed_out, :allow_empty, :min_examples)
|
|
368
394
|
```
|
|
369
|
-
Defaults live in `Config::DEFAULTS`. `boot` is `:per_process` or `:shared
|
|
395
|
+
Defaults live in `Config::DEFAULTS`. `boot` is `:per_process` or `:shared`; `unit_type`
|
|
396
|
+
is `"file"` or `"example"` (`--unit`).
|
|
370
397
|
`rspec_args` is the array after the gem's own flags (everything OptionParser did not
|
|
371
398
|
consume, plus everything after `--`). `report_args` are the raw args for the parent's
|
|
372
399
|
`--report-on-exit` report (built from the work flags: build, redis).
|
|
@@ -73,6 +73,7 @@ module RSpec
|
|
|
73
73
|
OptionParser.new do |o|
|
|
74
74
|
o.banner = BANNER
|
|
75
75
|
identity_options(o)
|
|
76
|
+
unit_options(o)
|
|
76
77
|
policy_options(o)
|
|
77
78
|
process_options(o)
|
|
78
79
|
lifetime_options(o)
|
|
@@ -90,6 +91,14 @@ module RSpec
|
|
|
90
91
|
opt.on("--revision SHA", "revision string mixed into the suite fingerprint") { |v| @opts[:revision] = v }
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
def unit_options(opt)
|
|
95
|
+
opt.separator ""
|
|
96
|
+
opt.separator "Work units:"
|
|
97
|
+
opt.on("--unit TYPE", UNIT_TYPES, "what one queue entry is: file (default) or example") do |v|
|
|
98
|
+
@opts[:unit_type] = v
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
93
102
|
def policy_options(opt)
|
|
94
103
|
opt.separator ""
|
|
95
104
|
opt.separator "Requeue and timeout policy:"
|
data/lib/rspec/hopper/config.rb
CHANGED
|
@@ -6,7 +6,7 @@ module RSpec
|
|
|
6
6
|
# collaborator; there is no global.
|
|
7
7
|
WorkConfig = Data.define(
|
|
8
8
|
:build_id, :worker_id, :redis_url,
|
|
9
|
-
:timeout, :max_unit_duration, :max_requeues, :requeue_tolerance, :max_reclaims,
|
|
9
|
+
:unit_type, :timeout, :max_unit_duration, :max_requeues, :requeue_tolerance, :max_reclaims,
|
|
10
10
|
:processes, :boot, :report_on_exit,
|
|
11
11
|
:ttl, :tombstone_ttl, :init_timeout, :revision,
|
|
12
12
|
:rspec_args, :report_args, :supervised
|
|
@@ -18,6 +18,8 @@ module RSpec
|
|
|
18
18
|
def heartbeat_interval
|
|
19
19
|
[timeout / 3.0, 30.0].min
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
def example_units? = unit_type == "example"
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# Frozen configuration for the `report` subcommand.
|
|
@@ -32,7 +34,7 @@ module RSpec
|
|
|
32
34
|
|
|
33
35
|
module Config
|
|
34
36
|
WORK_DEFAULTS = {
|
|
35
|
-
build_id: nil, worker_id: nil, redis_url: nil,
|
|
37
|
+
build_id: nil, worker_id: nil, redis_url: nil, unit_type: "file",
|
|
36
38
|
timeout: 180, max_unit_duration: 900, max_requeues: 0, requeue_tolerance: 0.0, max_reclaims: 3,
|
|
37
39
|
processes: 1, boot: :per_process, report_on_exit: false,
|
|
38
40
|
ttl: 14_400, tombstone_ttl: 604_800, init_timeout: 300, revision: nil,
|
|
@@ -25,12 +25,16 @@ module RSpec
|
|
|
25
25
|
# their descendants. Returns the number of examples reset.
|
|
26
26
|
def reset(example_groups)
|
|
27
27
|
Array(example_groups).sum do |group|
|
|
28
|
-
group.descendants.sum
|
|
29
|
-
descendant.filtered_examples.each { |example| reset_example(example) }.size
|
|
30
|
-
end
|
|
28
|
+
group.descendants.sum { |descendant| reset_examples(descendant.filtered_examples) }
|
|
31
29
|
end
|
|
32
30
|
end
|
|
33
31
|
|
|
32
|
+
# Resets exactly the given examples (an example unit's single example).
|
|
33
|
+
# Returns the number of examples reset.
|
|
34
|
+
def reset_examples(examples)
|
|
35
|
+
Array(examples).each { |example| reset_example(example) }.size
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
def reset_example(example)
|
|
35
39
|
example.instance_variable_set(:@exception, nil)
|
|
36
40
|
example.metadata[:execution_result] = RSpec::Core::Example::ExecutionResult.new
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hopper
|
|
7
|
+
# Runs a subset of an example group's selected examples through the public
|
|
8
|
+
# `ExampleGroup.run`, which is how example units execute one example while
|
|
9
|
+
# its file's context hooks still fire around it.
|
|
10
|
+
#
|
|
11
|
+
# `ExampleGroup.run` takes what to run from `RSpec.world.filtered_examples`
|
|
12
|
+
# and memoizes `descendant_filtered_examples` per group, which decides
|
|
13
|
+
# whether a group's `before(:context)`/`after(:context)` hooks run at all.
|
|
14
|
+
# Both are private to rspec-core. This is the second sanctioned touch of
|
|
15
|
+
# RSpec internals, granted for Phase 2 alongside ExampleReset: it swaps two
|
|
16
|
+
# pieces of state around one `run` and restores them afterwards, changes no
|
|
17
|
+
# method resolution, and is guarded by a contract spec pinned to 3.12-3.13.
|
|
18
|
+
module ExampleSubset
|
|
19
|
+
# The group-level memo that `ExampleGroup.run` consults through
|
|
20
|
+
# `descendant_filtered_examples`; the contract spec pins its presence.
|
|
21
|
+
MEMO_IVAR = :@descendant_filtered_examples
|
|
22
|
+
|
|
23
|
+
# Class-level instance variables of a run example group, pinned under
|
|
24
|
+
# bare rspec-core by the contract spec so new group state in a future
|
|
25
|
+
# release fails loudly rather than silently escaping the swap.
|
|
26
|
+
EXPECTED_GROUP_IVARS = %i[
|
|
27
|
+
@before_context_ivars @children @currently_executing_a_context_hook @descendant_filtered_examples
|
|
28
|
+
@examples @hooks @metadata @parent_groups @superclass_metadata @user_metadata
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Narrows `root` and its descendants to `examples` for the duration of
|
|
34
|
+
# the block. Groups with none of them selected run no examples and no
|
|
35
|
+
# context hooks. Returns the block's value.
|
|
36
|
+
def scoped(root, examples)
|
|
37
|
+
groups = root.descendants
|
|
38
|
+
saved = groups.to_h { |group| [group, [world.filtered_examples[group], memo(group)]] }
|
|
39
|
+
groups.each do |group|
|
|
40
|
+
world.filtered_examples[group] = saved[group].first.select { |example| examples.include?(example) }
|
|
41
|
+
group.instance_variable_set(MEMO_IVAR, nil)
|
|
42
|
+
end
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
saved&.each do |group, (filtered, memo)|
|
|
46
|
+
world.filtered_examples[group] = filtered
|
|
47
|
+
group.instance_variable_set(MEMO_IVAR, memo)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def memo(group) = group.instance_variable_get(MEMO_IVAR)
|
|
52
|
+
|
|
53
|
+
def world = RSpec.world
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -8,9 +8,9 @@ module RSpec
|
|
|
8
8
|
# Proves that workers loaded the same logical suite, not merely that they were
|
|
9
9
|
# given the same arguments. Computed after loading, over the normalized
|
|
10
10
|
# selection inputs, the ordering strategy name (never the seed), the sorted
|
|
11
|
-
# selected example ids
|
|
11
|
+
# selected example ids, the optional revision string and the unit type.
|
|
12
12
|
class Fingerprint
|
|
13
|
-
INPUT_KEYS = %w[file_args filter pattern exclude_pattern order example_ids revision].freeze
|
|
13
|
+
INPUT_KEYS = %w[file_args filter pattern exclude_pattern order example_ids revision unit_type].freeze
|
|
14
14
|
PROC_ADDRESS = /0x[0-9a-f]+@?/
|
|
15
15
|
# Per-input digests are recorded in the manifest so a mismatching worker
|
|
16
16
|
# can name the inputs that differ. Truncated: they are compared with each
|
|
@@ -25,7 +25,8 @@ module RSpec
|
|
|
25
25
|
# @param example_ids [Array<String>] ids of the selected examples
|
|
26
26
|
# @param file_args [Array<String>] normalized file arguments
|
|
27
27
|
# @param revision [String, nil]
|
|
28
|
-
|
|
28
|
+
# @param unit_type [String] "file" or "example"; a build has one unit type
|
|
29
|
+
def compute(configuration:, options:, example_ids:, file_args:, revision: nil, unit_type: "file")
|
|
29
30
|
filter_manager = configuration.filter_manager
|
|
30
31
|
new(
|
|
31
32
|
"file_args" => Array(file_args).map(&:to_s).sort,
|
|
@@ -37,7 +38,8 @@ module RSpec
|
|
|
37
38
|
"exclude_pattern" => configuration.exclude_pattern.to_s,
|
|
38
39
|
"order" => ordering_name(options.options[:order]),
|
|
39
40
|
"example_ids" => Array(example_ids).map(&:to_s).sort,
|
|
40
|
-
"revision" => revision&.to_s
|
|
41
|
+
"revision" => revision&.to_s,
|
|
42
|
+
"unit_type" => unit_type.to_s
|
|
41
43
|
)
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -123,7 +125,8 @@ module RSpec
|
|
|
123
125
|
"exclude_pattern=#{inputs["exclude_pattern"].inspect}",
|
|
124
126
|
"order=#{inputs["order"]}",
|
|
125
127
|
"example_ids=#{inputs["example_ids"].size}",
|
|
126
|
-
"revision=#{inputs["revision"].inspect}"
|
|
128
|
+
"revision=#{inputs["revision"].inspect}",
|
|
129
|
+
"unit_type=#{inputs["unit_type"]}"
|
|
127
130
|
].join(", ")
|
|
128
131
|
end
|
|
129
132
|
|
|
@@ -4,10 +4,11 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module RSpec
|
|
6
6
|
module Hopper
|
|
7
|
-
# Written once by the initializing worker into `meta`.
|
|
8
|
-
# of `file_counts` in order
|
|
7
|
+
# Written once by the initializing worker into `meta`. For file units the
|
|
8
|
+
# unit ids are the keys of `file_counts` in order and are not stored
|
|
9
|
+
# separately; for example units they are stored as `unit_ids`.
|
|
9
10
|
Manifest = Data.define(
|
|
10
|
-
:total_units, :total_examples, :file_counts, :file_args,
|
|
11
|
+
:total_units, :total_examples, :unit_type, :unit_ids, :file_counts, :file_args,
|
|
11
12
|
:fingerprint, :fingerprint_digests, :seed, :ready_at, :revision, :load_errors
|
|
12
13
|
) do
|
|
13
14
|
# Builds a Manifest from `meta` exactly as HGETALL returns it (all string
|
|
@@ -17,6 +18,8 @@ module RSpec
|
|
|
17
18
|
new(
|
|
18
19
|
total_units: meta.fetch("total_units"),
|
|
19
20
|
total_examples: meta.fetch("total_examples"),
|
|
21
|
+
unit_type: meta.fetch("unit_type", "file"),
|
|
22
|
+
unit_ids: meta["unit_ids"] && JSON.parse(meta["unit_ids"]),
|
|
20
23
|
file_counts: JSON.parse(meta.fetch("file_counts", "{}")),
|
|
21
24
|
file_args: JSON.parse(meta.fetch("file_args", "[]")),
|
|
22
25
|
fingerprint: meta["fingerprint"],
|
|
@@ -51,21 +54,35 @@ module RSpec
|
|
|
51
54
|
}
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
# File units are the files themselves, so their ids need not be given.
|
|
58
|
+
def self.unit_ids_for(type, unit_ids, counts)
|
|
59
|
+
ids = unit_ids || (type == "file" ? counts.keys : nil)
|
|
60
|
+
raise ArgumentError, "unit_ids are required for #{type} units" if ids.nil?
|
|
61
|
+
|
|
62
|
+
ids.map(&:to_s).freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def self.presence(value)
|
|
55
66
|
value.nil? || (value.respond_to?(:empty?) && value.empty?) ? nil : value
|
|
56
67
|
end
|
|
57
68
|
|
|
58
|
-
def initialize(total_examples:, file_counts:, file_args:,
|
|
59
|
-
seed: nil, total_units:
|
|
69
|
+
def initialize(total_examples:, file_counts:, file_args:, unit_type: "file", unit_ids: nil, fingerprint: nil,
|
|
70
|
+
fingerprint_digests: nil, seed: nil, total_units: nil, ready_at: nil, revision: nil,
|
|
71
|
+
load_errors: [])
|
|
60
72
|
counts = file_counts.to_h { |path, count| [path.to_s, Integer(count)] }.freeze
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
type = unit_type.to_s
|
|
74
|
+
raise ArgumentError, "unknown unit type #{type.inspect}" unless UNIT_TYPES.include?(type)
|
|
75
|
+
|
|
76
|
+
ids = self.class.unit_ids_for(type, unit_ids, counts)
|
|
77
|
+
units = Integer(total_units || ids.size)
|
|
78
|
+
raise ArgumentError, "total_units (#{units}) does not match the number of unit ids (#{ids.size})" if
|
|
79
|
+
units != ids.size
|
|
65
80
|
|
|
66
81
|
super(
|
|
67
82
|
total_units: units,
|
|
68
83
|
total_examples: Integer(total_examples),
|
|
84
|
+
unit_type: type,
|
|
85
|
+
unit_ids: ids,
|
|
69
86
|
file_counts: counts,
|
|
70
87
|
file_args: Array(file_args).map(&:to_s).freeze,
|
|
71
88
|
load_errors: Array(load_errors).map(&:to_s).freeze,
|
|
@@ -74,22 +91,27 @@ module RSpec
|
|
|
74
91
|
)
|
|
75
92
|
end
|
|
76
93
|
|
|
77
|
-
def
|
|
94
|
+
def file_units? = unit_type == "file"
|
|
95
|
+
def example_units? = unit_type == "example"
|
|
78
96
|
|
|
79
97
|
def init_failed? = load_errors.any?
|
|
80
98
|
|
|
81
99
|
def empty? = total_examples.zero?
|
|
82
100
|
|
|
83
101
|
# Hash of String => String for HSET. Nested fields are JSON-encoded;
|
|
84
|
-
# nil fields are omitted rather than written as empty strings.
|
|
102
|
+
# nil fields are omitted rather than written as empty strings. File
|
|
103
|
+
# unit ids are implied by `file_counts` and not written, so the meta of
|
|
104
|
+
# a file-unit build is what 0.1.0 wrote plus `unit_type`.
|
|
85
105
|
def to_meta
|
|
86
106
|
meta = {
|
|
87
107
|
"total_units" => total_units.to_s,
|
|
88
108
|
"total_examples" => total_examples.to_s,
|
|
109
|
+
"unit_type" => unit_type,
|
|
89
110
|
"file_counts" => JSON.generate(file_counts),
|
|
90
111
|
"file_args" => JSON.generate(file_args),
|
|
91
112
|
"load_errors" => JSON.generate(load_errors)
|
|
92
113
|
}
|
|
114
|
+
meta["unit_ids"] = JSON.generate(unit_ids) unless file_units?
|
|
93
115
|
optional_meta.each { |key, value| meta[key] = value unless value.nil? }
|
|
94
116
|
meta
|
|
95
117
|
end
|
|
@@ -236,7 +236,7 @@ module RSpec
|
|
|
236
236
|
|
|
237
237
|
def init_script(mode, token, manifest, unit_ids_json)
|
|
238
238
|
fields = manifest.to_meta.merge(budget_meta)
|
|
239
|
-
argv = [mode, token, ms(ttl), ms(tombstone_ttl), JSON.generate(fields), unit_ids_json,
|
|
239
|
+
argv = [mode, token, ms(ttl), ms(tombstone_ttl), JSON.generate(fields), unit_ids_json, manifest.unit_type]
|
|
240
240
|
run_script(:init, init_keys, argv)
|
|
241
241
|
rescue Redis::CommandError => e
|
|
242
242
|
code = e.message[/\A(?:ERR\s+)?([A-Z_]+)/, 1]
|
data/lib/rspec/hopper/report.rb
CHANGED
|
@@ -215,7 +215,8 @@ module RSpec
|
|
|
215
215
|
return "unknown (no manifest)" unless @manifest
|
|
216
216
|
|
|
217
217
|
finalized = @finalized_count.nil? ? "" : ", #{@finalized_count} finalized"
|
|
218
|
-
"#{@manifest.total_units}
|
|
218
|
+
"#{@manifest.total_units} #{@manifest.unit_type} units total#{finalized}; " \
|
|
219
|
+
"examples: #{@manifest.total_examples} selected"
|
|
219
220
|
end
|
|
220
221
|
|
|
221
222
|
def print_log_notes(out)
|
|
@@ -238,6 +239,7 @@ module RSpec
|
|
|
238
239
|
"exit_code" => outcome.exit_code,
|
|
239
240
|
"message" => outcome.headline,
|
|
240
241
|
"total_units" => @manifest&.total_units,
|
|
242
|
+
"unit_type" => @manifest&.unit_type,
|
|
241
243
|
"total_examples" => @manifest&.total_examples,
|
|
242
244
|
"finalized_count" => @finalized_count,
|
|
243
245
|
**log_summary,
|
data/lib/rspec/hopper/unit.rb
CHANGED
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Hopper
|
|
5
|
-
UNIT_TYPES = %w[file].freeze
|
|
5
|
+
UNIT_TYPES = %w[file example].freeze
|
|
6
6
|
|
|
7
|
-
# A schedulable unit of work
|
|
8
|
-
#
|
|
7
|
+
# A schedulable unit of work: one spec file (`file`, the default) or one
|
|
8
|
+
# example (`example`, `--unit example`). The `type` travels with the queue
|
|
9
|
+
# entry, the reservation and the manifest.
|
|
9
10
|
Unit = Data.define(:id, :type) do
|
|
10
11
|
def self.file(path) = new(id: path, type: "file")
|
|
12
|
+
def self.example(example_id) = new(id: example_id, type: "example")
|
|
13
|
+
|
|
14
|
+
def initialize(id:, type:)
|
|
15
|
+
type = type.to_s
|
|
16
|
+
raise ArgumentError, "unknown unit type #{type.inspect}; expected one of #{UNIT_TYPES.join(", ")}" unless
|
|
17
|
+
UNIT_TYPES.include?(type)
|
|
18
|
+
|
|
19
|
+
super(id: id.to_s, type: type)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def file? = type == "file"
|
|
23
|
+
def example? = type == "example"
|
|
11
24
|
|
|
12
25
|
def to_h = { "id" => id, "type" => type }
|
|
13
26
|
end
|
data/lib/rspec/hopper/version.rb
CHANGED
|
@@ -7,7 +7,8 @@ module RSpec
|
|
|
7
7
|
module Hopper
|
|
8
8
|
class Worker
|
|
9
9
|
# Loads the RSpec suite once per process, rejects unsupported options,
|
|
10
|
-
# discovers
|
|
10
|
+
# discovers the units (`config.unit_type`), computes the fingerprint and
|
|
11
|
+
# adopts the build seed.
|
|
11
12
|
# This is the only place that mutates RSpec.configuration.
|
|
12
13
|
class Suite
|
|
13
14
|
RUNNER_OPTIONS = {
|
|
@@ -48,6 +49,7 @@ module RSpec
|
|
|
48
49
|
@err = err
|
|
49
50
|
@load_errors = []
|
|
50
51
|
@groups_by_file = {}
|
|
52
|
+
@unit_examples = {}
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def configuration = RSpec.configuration
|
|
@@ -63,10 +65,7 @@ module RSpec
|
|
|
63
65
|
reject_unsupported_configuration!
|
|
64
66
|
normalize_file_args!
|
|
65
67
|
load_spec_files
|
|
66
|
-
|
|
67
|
-
discover_units
|
|
68
|
-
@fingerprint = Fingerprint.compute(configuration: configuration, options: options, example_ids: example_ids,
|
|
69
|
-
file_args: file_args, revision: config.revision)
|
|
68
|
+
describe_loaded_suite
|
|
70
69
|
self
|
|
71
70
|
rescue InfrastructureError
|
|
72
71
|
raise
|
|
@@ -74,13 +73,52 @@ module RSpec
|
|
|
74
73
|
raise BootError, "#{e.class}: #{e.message}", e.backtrace
|
|
75
74
|
end
|
|
76
75
|
|
|
76
|
+
# Modules prepended onto `RSpec::Core::Runner` that define `run_specs`.
|
|
77
|
+
# A prepend on the superclass cannot intercept a method the subclass
|
|
78
|
+
# defines, and the worker's Runner defines `run_specs` itself, so these
|
|
79
|
+
# never run here.
|
|
80
|
+
#
|
|
81
|
+
# @param base [Class] the class to inspect; injectable for specs
|
|
82
|
+
def self.runner_wrappers(base = RSpec::Core::Runner)
|
|
83
|
+
base.ancestors
|
|
84
|
+
.take_while { |mod| !mod.equal?(base) }
|
|
85
|
+
.select { |mod| mod.method_defined?(:run_specs, false) }
|
|
86
|
+
.map { |mod| mod.name || mod.inspect }
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def unit_ids = units.map(&:id)
|
|
90
|
+
def unit_type = config.unit_type
|
|
91
|
+
def example_units? = config.example_units?
|
|
92
|
+
|
|
93
|
+
# The top-level groups a unit runs through: every group of the file for
|
|
94
|
+
# a file unit, the one group containing the example for an example unit.
|
|
95
|
+
def groups_for(unit_id)
|
|
96
|
+
return @groups_by_file.fetch(unit_id, []) unless example_units?
|
|
97
|
+
|
|
98
|
+
group, _example = @unit_examples[unit_id]
|
|
99
|
+
group ? [group] : []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Selected examples of the unit: the file's, including nested groups,
|
|
103
|
+
# or the one example.
|
|
104
|
+
def examples_for(unit_id)
|
|
105
|
+
return groups_for(unit_id).flat_map(&:descendant_filtered_examples) unless example_units?
|
|
78
106
|
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
_group, example = @unit_examples[unit_id]
|
|
108
|
+
example ? [example] : []
|
|
109
|
+
end
|
|
81
110
|
|
|
82
|
-
#
|
|
83
|
-
|
|
111
|
+
# Executes one unit through `ExampleGroup.run`, after resetting its
|
|
112
|
+
# examples so a retry in this process starts clean. An example unit
|
|
113
|
+
# narrows its group to the one example for the duration of the run.
|
|
114
|
+
def run_unit(unit_id, reporter)
|
|
115
|
+
groups = groups_for(unit_id)
|
|
116
|
+
examples = examples_for(unit_id)
|
|
117
|
+
ExampleReset.reset_examples(examples)
|
|
118
|
+
return groups.each { |group| group.run(reporter) } unless example_units?
|
|
119
|
+
|
|
120
|
+
ExampleSubset.scoped(groups.first, examples) { groups.first.run(reporter) }
|
|
121
|
+
end
|
|
84
122
|
|
|
85
123
|
# Sets the build seed without changing the global ordering strategy:
|
|
86
124
|
# `Configuration#seed=` switches an unforced global ordering to random,
|
|
@@ -112,6 +150,7 @@ module RSpec
|
|
|
112
150
|
def to_manifest(revision: config.revision)
|
|
113
151
|
Manifest.new(
|
|
114
152
|
total_examples: total_examples, file_counts: file_counts, file_args: file_args,
|
|
153
|
+
unit_type: unit_type, unit_ids: unit_ids,
|
|
115
154
|
fingerprint: fingerprint.value, fingerprint_digests: fingerprint.digests,
|
|
116
155
|
seed: configuration.seed, revision: revision, load_errors: load_errors
|
|
117
156
|
)
|
|
@@ -119,6 +158,34 @@ module RSpec
|
|
|
119
158
|
|
|
120
159
|
private
|
|
121
160
|
|
|
161
|
+
# Everything that depends on the spec files being loaded: instrumentation
|
|
162
|
+
# is installed by then, and the units and fingerprint come from the
|
|
163
|
+
# groups the load produced.
|
|
164
|
+
def describe_loaded_suite
|
|
165
|
+
warn_about_runner_wrappers
|
|
166
|
+
world.announce_filters
|
|
167
|
+
discover_units
|
|
168
|
+
@fingerprint = Fingerprint.compute(configuration: configuration, options: options, example_ids: example_ids,
|
|
169
|
+
file_args: file_args, revision: config.revision,
|
|
170
|
+
unit_type: config.unit_type)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The per-example patches such gems install still work — that is what
|
|
174
|
+
# the no-prepend promise is about — so the failure is otherwise silent:
|
|
175
|
+
# spans keep being produced with no session for them to belong to, and
|
|
176
|
+
# the build looks instrumented while reporting nothing.
|
|
177
|
+
def warn_about_runner_wrappers
|
|
178
|
+
wrappers = self.class.runner_wrappers
|
|
179
|
+
return if wrappers.empty?
|
|
180
|
+
|
|
181
|
+
@err.puts "[hopper #{config.worker_id}] #{wrappers.join(", ")} wraps " \
|
|
182
|
+
"RSpec::Core::Runner#run_specs, which rspec-hopper replaces, so that wrapper will not " \
|
|
183
|
+
"run. Instrumentation that starts there (datadog-ci's test session and module, for " \
|
|
184
|
+
"one) must be started from a before(:suite) hook and finished in after(:suite): hopper " \
|
|
185
|
+
"runs those once per worker process. See the README."
|
|
186
|
+
@err.flush if @err.respond_to?(:flush)
|
|
187
|
+
end
|
|
188
|
+
|
|
122
189
|
def reject_init_option!
|
|
123
190
|
env_args = ENV["SPEC_OPTS"] ? Shellwords.split(ENV["SPEC_OPTS"]) : []
|
|
124
191
|
return unless (rspec_args + env_args).include?("--init")
|
|
@@ -195,9 +262,32 @@ module RSpec
|
|
|
195
262
|
end
|
|
196
263
|
end
|
|
197
264
|
.freeze
|
|
198
|
-
@units = @file_counts.keys.map { |path| Unit.file(path) }.freeze
|
|
199
265
|
@total_examples = @file_counts.values.sum
|
|
200
266
|
@example_ids = @groups_by_file.values.flatten.flat_map(&:descendant_filtered_examples).map(&:id).sort.freeze
|
|
267
|
+
@units = (example_units? ? example_units : file_units).freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def file_units = @file_counts.keys.map { |path| Unit.file(path) }
|
|
271
|
+
|
|
272
|
+
# One unit per selected example, in the order RSpec would run them under
|
|
273
|
+
# this worker's configuration and seed (top-level groups as the world
|
|
274
|
+
# orders them, which interleaves files under random ordering), so the
|
|
275
|
+
# queue order is the ordering the initializer publishes with the seed.
|
|
276
|
+
def example_units
|
|
277
|
+
world.ordered_example_groups.each do |group|
|
|
278
|
+
next if group.descendant_filtered_examples.empty?
|
|
279
|
+
|
|
280
|
+
ordered_examples(group).each { |example| @unit_examples[example.id] = [group, example] }
|
|
281
|
+
end
|
|
282
|
+
@unit_examples.keys.map { |id| Unit.example(id) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# A group's selected examples then its children's, each in the group's
|
|
286
|
+
# own ordering: the sequence `ExampleGroup.run` would execute.
|
|
287
|
+
def ordered_examples(group)
|
|
288
|
+
strategy = group.ordering_strategy
|
|
289
|
+
strategy.order(group.filtered_examples) +
|
|
290
|
+
strategy.order(group.children).flat_map { |child| ordered_examples(child) }
|
|
201
291
|
end
|
|
202
292
|
end
|
|
203
293
|
end
|
data/lib/rspec/hopper/worker.rb
CHANGED
|
@@ -6,7 +6,8 @@ require_relative "worker/runner"
|
|
|
6
6
|
module RSpec
|
|
7
7
|
module Hopper
|
|
8
8
|
# The RSpec adapter: loads the suite, joins or initializes the build, pulls
|
|
9
|
-
# units, runs each through `ExampleGroup.run
|
|
9
|
+
# units, runs each through `ExampleGroup.run` (a file's groups, or one
|
|
10
|
+
# example narrowed within its group), decides requeue from the
|
|
10
11
|
# execution results and forwards final attempts to the formatters.
|
|
11
12
|
class Worker
|
|
12
13
|
POLL_INTERVAL = 0.5
|
|
@@ -176,7 +177,7 @@ module RSpec
|
|
|
176
177
|
outcome = phase(:execution, unit_id: unit_id) do
|
|
177
178
|
raise InfrastructureError, "unit #{unit_id} is not part of this worker's suite" if groups.empty?
|
|
178
179
|
|
|
179
|
-
|
|
180
|
+
run_unit(buffer, reservation)
|
|
180
181
|
end
|
|
181
182
|
decision = RequeuePolicy.decide(examples, escaped: outcome.escaped)
|
|
182
183
|
phase(:execution, unit_id: unit_id) do
|
|
@@ -191,15 +192,14 @@ module RSpec
|
|
|
191
192
|
alert "#{outcome.escaped.class} escaped an example in #{unit_id}; the unit was finalized as failed"
|
|
192
193
|
end
|
|
193
194
|
|
|
194
|
-
def
|
|
195
|
-
ExampleReset.reset(groups)
|
|
195
|
+
def run_unit(buffer, reservation)
|
|
196
196
|
heartbeat = Heartbeat.new(queue: queue, reservation: reservation, config: config, err: @err, clock: @clock,
|
|
197
197
|
aborter: @aborter, worker_id: worker_id)
|
|
198
198
|
started = @clock.call
|
|
199
199
|
escaped = nil
|
|
200
200
|
heartbeat.start
|
|
201
201
|
begin
|
|
202
|
-
|
|
202
|
+
suite.run_unit(reservation.unit_id, buffer)
|
|
203
203
|
rescue *RequeuePolicy::NON_REQUEUEABLE => e
|
|
204
204
|
escaped = e
|
|
205
205
|
ensure
|
data/lib/rspec/hopper.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "hopper/queue"
|
|
|
14
14
|
require_relative "hopper/queue/redis_streams"
|
|
15
15
|
require_relative "hopper/fingerprint"
|
|
16
16
|
require_relative "hopper/example_reset"
|
|
17
|
+
require_relative "hopper/example_subset"
|
|
17
18
|
require_relative "hopper/worker/buffering_reporter"
|
|
18
19
|
require_relative "hopper/worker/heartbeat"
|
|
19
20
|
require_relative "hopper/worker/requeue_policy"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-hopper
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Simpson
|
|
@@ -91,6 +91,7 @@ files:
|
|
|
91
91
|
- lib/rspec/hopper/config.rb
|
|
92
92
|
- lib/rspec/hopper/errors.rb
|
|
93
93
|
- lib/rspec/hopper/example_reset.rb
|
|
94
|
+
- lib/rspec/hopper/example_subset.rb
|
|
94
95
|
- lib/rspec/hopper/fingerprint.rb
|
|
95
96
|
- lib/rspec/hopper/keys.rb
|
|
96
97
|
- lib/rspec/hopper/manifest.rb
|
|
@@ -116,6 +117,7 @@ metadata:
|
|
|
116
117
|
homepage_uri: https://github.com/prsimp/rspec-hopper
|
|
117
118
|
source_code_uri: https://github.com/prsimp/rspec-hopper
|
|
118
119
|
changelog_uri: https://github.com/prsimp/rspec-hopper/blob/main/CHANGELOG.md
|
|
120
|
+
documentation_uri: https://github.com/prsimp/rspec-hopper/blob/main/docs/DESIGN.md
|
|
119
121
|
rubygems_mfa_required: 'true'
|
|
120
122
|
rdoc_options: []
|
|
121
123
|
require_paths:
|