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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ac7938a8f8be5dfeaa3fe71e48c5f9a09ef34db00b94903a8e89e0862399571
4
- data.tar.gz: 2f5f71d073fb570c482fd5d57a326cebf5a32e5a444d8a074ec729b4726cd09d
3
+ metadata.gz: 38c293f2c7a326a8b7ee5cd6883a72f392b55855eff1ad33a4863453dd6bc98a
4
+ data.tar.gz: a104e430ab3d22c8c8494a561fd57175039dadf3aed0a4b6f3f709f0e9e9a901
5
5
  SHA512:
6
- metadata.gz: 3f8b0fe48942ad2724c23cea372d4a2ebf86bb7008d0e43f9300633f90e54f8ced37a81036c519fa2c0554d2df08582aab8af2f0210c5a1ea443468dc3137efc
7
- data.tar.gz: 9ce47aee0f8525833c9974a77482ccd010439be6b904cf996086bd5664b14e82d4ac1865e418160481bf0c6166691b6d46ba67c623e98a5c587613099381baaa
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
+ [![Gem Version](https://badge.fury.io/rb/rspec-hopper.svg)](https://badge.fury.io/rb/rspec-hopper)
4
+ [![CI](https://dl.circleci.com/status-badge/img/gh/prsimp/rspec-hopper/tree/main.svg?style=shield)](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 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.
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 instrumentation gems that do
25
- (datadog-ci, rspec-retry and others) keep working.
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 spec file per line (failed and never-finalized units), so
74
- `bundle exec rspec $(cat tmp/hopper-failed.txt)` reruns them locally.
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, plus a manifest (unit and 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 that file's top-level example groups through
93
- `ExampleGroup.run`, then finalize the unit as passed or failed, or requeue it.
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 file. |
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 file is
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 file makes the attempt final. When a
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.connection.current_database
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`. 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.
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
- ## Phase 1 non-goals
650
+ ## Non-goals
514
651
 
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.
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, :file_counts, :file_args,
241
- :fingerprint, :fingerprint_digests, :seed, :ready_at, :revision, :load_errors)` —
242
- `#unit_ids` is derived: the
243
- keys of `file_counts` in order (`total_units == unit_ids.size`).
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 unit
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/...`). Example ids for the
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:)`): SHA256 over
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:}`. Also returns the
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 group for the unit through
292
- `group.run(buffering_reporter)` in `ordered_example_groups` order, stop heartbeat,
293
- then `RequeuePolicy.decide(examples)`.
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:"
@@ -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 do |descendant|
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 and the optional revision string.
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
- def compute(configuration:, options:, example_ids:, file_args:, revision: nil)
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`. Unit ids are the keys
8
- # of `file_counts` in order; they are not stored separately.
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:, fingerprint: nil, fingerprint_digests: nil,
59
- seed: nil, total_units: file_counts.size, ready_at: nil, revision: nil, load_errors: [])
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
- units = Integer(total_units)
62
- if units != counts.size
63
- raise ArgumentError, "total_units (#{units}) does not match file_counts.size (#{counts.size})"
64
- end
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 unit_ids = file_counts.keys
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, "file"]
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]
@@ -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} total#{finalized}; examples: #{@manifest.total_examples} selected"
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,
@@ -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. Phase 1 units are spec files; the `type`
8
- # field exists so example-level units can be added without a schema change.
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Hopper
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -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 file units, computes the fingerprint and adopts the build seed.
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
- world.announce_filters
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
- # Top-level groups whose file is the unit, in configured order.
80
- def groups_for(unit_id) = @groups_by_file.fetch(unit_id, [])
107
+ _group, example = @unit_examples[unit_id]
108
+ example ? [example] : []
109
+ end
81
110
 
82
- # Selected examples of the unit, including nested groups.
83
- def examples_for(unit_id) = groups_for(unit_id).flat_map(&:descendant_filtered_examples)
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
@@ -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`, decides requeue from the
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
- run_groups(groups, buffer, reservation)
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 run_groups(groups, buffer, reservation)
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
- groups.each { |group| group.run(buffer) }
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.1.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: