rspec-hopper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. metadata +137 -0
data/docs/DESIGN.md ADDED
@@ -0,0 +1,386 @@
1
+ # rspec-hopper internal design contract
2
+
3
+ This document is the contract between the gem's layers. Every implementer works from
4
+ it; when it and the product spec (`../rspec-hopper-init.md`, not in this repo)
5
+ disagree, the product spec wins and this file must be corrected. Nothing in here is
6
+ user documentation; the README is written separately.
7
+
8
+ ## Layout
9
+
10
+ ```
11
+ lib/rspec/hopper.rb requires everything; before_fork/after_fork hook registry
12
+ lib/rspec/hopper/version.rb
13
+ lib/rspec/hopper/errors.rb error hierarchy and exit codes
14
+ lib/rspec/hopper/config.rb WorkConfig / ReportConfig (frozen Data)
15
+ lib/rspec/hopper/unit.rb Unit value object
16
+ lib/rspec/hopper/reservation.rb Reservation handle value object
17
+ lib/rspec/hopper/keys.rb Redis key naming
18
+ lib/rspec/hopper/queue.rb Queue protocol (abstract) + result structs
19
+ lib/rspec/hopper/queue/redis_streams.rb the one adapter
20
+ lib/rspec/hopper/queue/redis_streams/lua/init.lua
21
+ lib/rspec/hopper/queue/redis_streams/lua/transition.lua
22
+ lib/rspec/hopper/attempt_log.rb Event + queries
23
+ lib/rspec/hopper/manifest.rb
24
+ lib/rspec/hopper/fingerprint.rb
25
+ lib/rspec/hopper/example_reset.rb
26
+ lib/rspec/hopper/worker.rb the RSpec adapter: loop, init/election, completion
27
+ lib/rspec/hopper/worker/suite.rb loads RSpec, discovers units, option checks, seed adoption
28
+ lib/rspec/hopper/worker/buffering_reporter.rb
29
+ lib/rspec/hopper/worker/heartbeat.rb
30
+ lib/rspec/hopper/worker/requeue_policy.rb
31
+ lib/rspec/hopper/report.rb verdict, exit code, JSON summary
32
+ lib/rspec/hopper/ci_env.rb build/worker id inference from CI env vars
33
+ lib/rspec/hopper/cli.rb dispatch: work | report
34
+ lib/rspec/hopper/cli/work.rb OptionParser -> WorkConfig; strips --format/--out for children
35
+ lib/rspec/hopper/cli/formatter_args.rb --format/--out splitting; per-child output files
36
+ lib/rspec/hopper/cli/report.rb OptionParser -> ReportConfig
37
+ lib/rspec/hopper/supervisor.rb --processes N, boot modes, signal forwarding, exit precedence
38
+ exe/rspec-hopper
39
+ spec/spec_helper.rb loads spec/support/**/*.rb
40
+ spec/support/redis_helper.rb TEST redis url, flush helper, key/TTL scan
41
+ spec/rspec/hopper/** unit specs, mirror lib layout
42
+ spec/contract/** example_reset, prepend coexistence
43
+ spec/fixtures/suites/<name>/ fixture suites (each has .rspec, spec/, optional spec_helper)
44
+ spec/integration/** spawn real `exe/rspec-hopper work` processes
45
+ ```
46
+
47
+ Namespace is `RSpec::Hopper` (capital S). Runtime deps: rspec-core, redis, json only.
48
+
49
+ ## Redis
50
+
51
+ Test server: `ENV["HOPPER_TEST_REDIS_URL"]`, default `redis://127.0.0.1:6399/0`. Specs
52
+ must never touch db 0 on port 6379 by default. Every spec that touches Redis uses a
53
+ unique build id (`"spec-#{SecureRandom.hex(6)}"`) and deletes its keys afterwards.
54
+
55
+ Key layout, all under `hopper:{<build>}:` (`Keys.for(build_id)`):
56
+
57
+ | key | type | notes |
58
+ |------------------|--------|---------------------------------------------------------------|
59
+ | `units` | stream | consumer group `workers`, created at id `0` with MKSTREAM |
60
+ | `units:priority` | stream | same; requeued entries go here |
61
+ | `attempts` | stream | attempt log; entries have one field `json` |
62
+ | `meta` | hash | manifest fields + `state`, `ready_at`, `finalized_count`, `requeued_units_count` |
63
+ | `unit_state` | hash | unit id -> JSON `{"retry_index":0,"reclaim_count":0,"entered_retry":false}` |
64
+ | `workers` | hash | worker id -> JSON `{"last_seen":<epoch ms int>,"current_unit":<id or null>,"processed":<int>}` |
65
+ | `leader` | string | `<worker_id>:<nonce>`, `SET NX EX 60`, never renewed |
66
+ | `exists` | string | tombstone, `"1"`, TTL `tombstone_ttl`, never renewed |
67
+
68
+ Stream entry fields for a unit: `id` (unit id), `type` (`file`). `attempts` entries:
69
+ single field `json`.
70
+
71
+ `meta` fields (all strings in Redis): `state` (`ready`|`init_failed`), `ready_at`
72
+ (epoch ms), `finalized_count`, `requeued_units_count`, `total_units`,
73
+ `total_examples`, `file_counts` (JSON object path -> int), `file_args` (JSON array),
74
+ `fingerprint`, `fingerprint_digests` (JSON object: one short digest per fingerprint
75
+ input plus `example_ids_count`, used to explain a mismatch), `seed`, `revision` (may be
76
+ absent), `load_errors` (JSON array of strings),
77
+ `max_requeues`, `requeue_tolerance`, `max_reclaims`, `timeout`, `ttl`. The budget
78
+ values are frozen into meta at initialization; the transition script reads them from
79
+ `meta` first and falls back to ARGV only when the field is absent, so every worker
80
+ enforces the initializer's caps regardless of its own flags. No warning is printed.
81
+
82
+ TTL rules: every key except `leader` and `exists` gets `PEXPIRE ttl_ms` inside the same
83
+ Lua script or MULTI that writes it. `leader` has `EX 60`; `exists` has
84
+ `tombstone_ttl`. Reads never touch TTLs. `report` never writes.
85
+
86
+ Timestamps: Lua uses `redis.call('TIME')` -> epoch milliseconds integer; Ruby passes
87
+ nothing clock-related to scripts. Events carry `at_ms`.
88
+
89
+ ### Lua scripts (exactly two)
90
+
91
+ `init.lua` — KEYS: units, units:priority, attempts, meta, unit_state, leader, exists.
92
+ ARGV[1] = mode (`success`|`failure`), ARGV[2] = lease token, ARGV[3] = ttl_ms,
93
+ ARGV[4] = tombstone_ttl_ms, ARGV[5] = JSON meta fields (object of string->string),
94
+ ARGV[6] = JSON array of unit ids (success only). Aborts with error `LEASE_LOST` if
95
+ `leader != token`, `ALREADY_INITIALIZED` if `exists` present, `ALREADY_READY` if `meta`
96
+ present. Success: DEL both unit streams, `XGROUP CREATE <s> workers 0 MKSTREAM` on
97
+ both, XADD each unit (`id`, `type`), HSET `unit_state` for each unit with zeros,
98
+ HSET meta fields + `state=ready`, `ready_at`, `finalized_count=0`,
99
+ `requeued_units_count=0`, SET `exists 1 PX tombstone_ttl_ms`, PEXPIRE the rest. Failure
100
+ mode: HSET meta fields + `state=init_failed`, `ready_at`, SET tombstone, PEXPIRE.
101
+ Returns `"ready"` or `"init_failed"`.
102
+
103
+ `transition.lua` — KEYS: units, units:priority, attempts, meta, unit_state, workers.
104
+ ARGV[1] = mode, ARGV[2] = worker_id, ARGV[3] = ttl_ms, then mode-specific ARGV.
105
+ Every mode except `reclaim` receives the handle: stream name (`units` or
106
+ `units:priority`), entry_id, delivery_count. The fence: `XPENDING <stream> workers
107
+ <entry_id> <entry_id> 1`; if the entry is absent, or its consumer != worker_id, or its
108
+ delivery count != handle's, return `{"STALE"}` (a status array, not a Lua error, so the
109
+ caller can distinguish it from Redis errors). Any mode that needs `unit_state[unit]` and
110
+ finds it missing returns `{"CORRUPT"}`. Any mode that finds `meta` missing (`EXISTS
111
+ meta == 0`) returns `{"CORRUPT"}`. All modes end by PEXPIREing every live key that
112
+ exists (units, units:priority, attempts, meta, unit_state, workers).
113
+
114
+ Modes and returns (arrays; first element is a status string):
115
+
116
+ * `delivery_accounting` ARGV: stream, entry_id, unit_id. Fence by consumer only (delivery
117
+ count is read from XPENDING and returned). Reads unit_state for retry_index and
118
+ reclaim_count, appends `delivered`, HSET workers[worker_id] with `current_unit`. Returns
119
+ `{"OK", delivery_count, retry_index, reclaim_count}`.
120
+ * `reclaim` ARGV: timeout_ms, max_reclaims. For each of `units:priority` then `units`:
121
+ `XAUTOCLAIM <stream> workers <worker_id> <timeout_ms> 0-0 COUNT 1`. Reply is
122
+ `{next_id, entries}` on 6.2 and `{next_id, entries, deleted_ids}` on 7+; entries may
123
+ contain `false`/nil placeholders for deleted entries on 6.2 — skip those. If an entry
124
+ is claimed: read its fields, XPENDING for delivery_count, read unit_state (CORRUPT if
125
+ missing). `previous_worker_id` is unknown after XAUTOCLAIM has moved ownership, so the
126
+ script reads `XPENDING` *before* XAUTOCLAIM for the oldest idle entry of the stream
127
+ (`XPENDING <stream> workers IDLE <timeout_ms> - + 1`) to learn the candidate and its
128
+ consumer, then XAUTOCLAIMs; if XAUTOCLAIM returned a different entry than the
129
+ candidate, use whatever XAUTOCLAIM returned and record previous_worker_id as the
130
+ XPENDING result only when the ids match, else `null`. If `reclaim_count + 1 >
131
+ max_reclaims`: XACK the entry, append `finalized` with outcome `failed`, reason
132
+ `reclaim_budget_exhausted`, `HINCRBY meta finalized_count 1`, and return
133
+ `{"FINALIZED", unit_id}`. Otherwise increment reclaim_count in unit_state, append
134
+ `reclaimed` (with `previous_worker_id`), set workers[worker_id].current_unit, return
135
+ `{"OK", stream, entry_id, unit_id, unit_type, delivery_count, retry_index, reclaim_count}`.
136
+ If nothing was claimed from either stream return `{"NONE"}`.
137
+ * `heartbeat` ARGV: stream, entry_id, delivery_count, unit_id. Fence; `XCLAIM <stream>
138
+ workers <worker_id> 0 <entry_id> JUSTID`; HSET workers[worker_id] last_seen/current_unit.
139
+ Returns `{"OK"}`.
140
+ * `finalize` ARGV: stream, entry_id, delivery_count, unit_id, outcome, duration_ms,
141
+ reason (may be empty), errors_json (may be empty; already size-capped by Ruby).
142
+ Fence; XACK; append `finalized`; `HINCRBY meta finalized_count 1`; HSET
143
+ workers[worker_id] with current_unit null and processed+1. Returns `{"OK"}`.
144
+ * `requeue` ARGV: stream, entry_id, delivery_count, unit_id, unit_type, max_requeues,
145
+ requeue_tolerance (string float), duration_ms, failure_summary, errors_json. Fence;
146
+ read unit_state (CORRUPT if missing); `max_requeued = ceil(total_units * tolerance)`;
147
+ if `retry_index + 1 > max_requeues` or (`entered_retry == false` and
148
+ `requeued_units_count >= max_requeued`): behave exactly like `finalize` with outcome
149
+ `failed`, reason `retry_budget_exhausted`, and return `{"FINALIZED", retry_index}`.
150
+ Otherwise: if not entered_retry, set it and `HINCRBY meta requeued_units_count 1`;
151
+ retry_index += 1; write unit_state; XACK old entry; XADD `units:priority` `id`, `type`;
152
+ append `requeued` (carries `failure_summary`, `errors`, `previous_retry_index`; its
153
+ `retry_index` is the post-increment value); HSET workers[worker_id] current_unit null,
154
+ processed+1. Returns `{"REQUEUED", new_retry_index}`.
155
+ * `liveness` ARGV: current_unit (may be empty). HSET workers[worker_id] last_seen (+
156
+ current_unit) and PEXPIRE live keys. Returns `{"OK"}`. (Idle-loop liveness; the spec's
157
+ "atomically with a TTL renewal".) If `meta` is missing return `{"CORRUPT"}`.
158
+ * `abandoned` ARGV: handle + elapsed_ms. Fenced; appends `abandoned`. Returns `{"OK"}`.
159
+ * `worker_error` ARGV: json event (built in Ruby, the script only stamps `at_ms`),
160
+ appends to attempts, PEXPIRE. Returns `{"OK"}`. Does not check meta (may be called
161
+ before ready, e.g. boot errors after another worker initialized; if `attempts` does not
162
+ exist yet the XADD creates it and PEXPIRE covers it).
163
+ * `stale_rejected` ARGV: json event; same as worker_error.
164
+
165
+ Event JSON built inside Lua uses `cjson.encode` of a table; Ruby-built payloads are
166
+ passed pre-encoded and embedded as raw JSON strings (concatenate, do not double-encode).
167
+ `unit_id`, `worker_id`, `retry_index`, `reclaim_count`, `ownership_generation`
168
+ (= 1 + retry_index + reclaim_count), `at_ms`, `type` on every unit-scoped event.
169
+
170
+ ### Ruby interface: `RSpec::Hopper::Queue::RedisStreams`
171
+
172
+ ```ruby
173
+ Queue::RedisStreams.new(redis:, build_id:, ttl:, tombstone_ttl:, timeout:,
174
+ max_requeues:, requeue_tolerance:, max_reclaims:)
175
+ ```
176
+ All keyword args except `redis:`/`build_id:` default to the product-spec defaults. Times
177
+ in seconds (Integer/Float); the adapter converts to ms.
178
+
179
+ | method | returns / raises |
180
+ |---|---|
181
+ | `status` | `Queue::Status.new(state:, tombstone:, meta:)` — `state` is `nil`, `"ready"` or `"init_failed"`; `meta` a Hash of strings or nil. One MULTI. |
182
+ | `acquire_leader(worker_id)` | lease token String or nil |
183
+ | `initialize_build(token:, manifest:, unit_ids:)` | `:ready`; raises `LeaseLost`, `PreviouslyInitialized` (tombstone), `AlreadyInitialized` (meta present) |
184
+ | `fail_initialization(token:, manifest:)` | `:init_failed`; same raises. `manifest.load_errors` non-empty. |
185
+ | `manifest` | `Manifest` or nil |
186
+ | `reclaim_lost(worker_id)` | `Reservation` or nil (nil also when the script finalized a unit as reclaim_budget_exhausted; that outcome is in the attempt log) |
187
+ | `reserve(worker_id, block_ms: 1000)` | `Reservation` or nil. XREADGROUP priority (no block) then units (BLOCK block_ms), then delivery_accounting. If accounting returns STALE (someone reclaimed it between read and accounting), return nil. |
188
+ | `heartbeat(reservation)` | `true`; raises `StaleReservation`, `CorruptBuild` |
189
+ | `finalize(reservation, outcome:, duration_ms:, reason: nil, errors: nil)` | `true`; raises `StaleReservation`, `CorruptBuild`. `outcome` is `:passed`/`:failed`; `reason` one of `test_failure`, `retry_budget_exhausted`, `reclaim_budget_exhausted` (required when failed); `errors` an Array of Hashes (see error payload) capped by `ErrorPayload.cap`. |
190
+ | `requeue(reservation, duration_ms:, failure_summary:, errors:)` | `Queue::RequeueResult.new(status:, retry_index:)` with status `:requeued` or `:finalized`; raises `StaleReservation`, `CorruptBuild` |
191
+ | `record_worker_error(worker_id:, phase:, error:, unit_id: nil)` | `true`. `phase` in `boot init reserve execution redis formatter`. Builds the event in Ruby from an Exception (class, capped message, capped backtrace). |
192
+ | `record_stale_rejected(reservation, operation:)` | `true` |
193
+ | `touch_liveness(worker_id, current_unit: nil)` | `true`; raises `CorruptBuild` if meta gone |
194
+ | `complete?` | `true`/`false`; raises `BuildStateMissing` if meta gone |
195
+ | `finalized_count`, `total_units` | Integer; raise `BuildStateMissing` if meta gone |
196
+ | `attempt_events` | `Array<Hash>` (symbol keys? NO — string keys, exactly as decoded from JSON) in stream order |
197
+ | `workers` | `Hash<String, Hash>` worker id -> `{"last_seen"=>ms, "current_unit"=>..., "processed"=>n}` |
198
+ | `unit_states` | `Hash<String, Hash>` unit id -> `{"retry_index"=>, "reclaim_count"=>, "entered_retry"=>}` |
199
+ | `keys` | Array of the build's existing key names (for TTL scans, via SCAN MATCH `hopper:{build}:*`) |
200
+
201
+ `Reservation = Data.define(:unit_id, :unit_type, :stream, :entry_id, :consumer,
202
+ :delivery_count, :retry_index, :reclaim_count)` with `#ownership_generation` and `#unit`.
203
+ `stream` holds the *short* name (`"units"` / `"units:priority"`); the adapter maps to
204
+ full keys.
205
+
206
+ Error payload (Ruby side, `ErrorPayload` in errors.rb): array of
207
+ `{"example_id"=>, "description"=>, "class"=>, "message"=>, "backtrace"=>[...]}`; message
208
+ capped at 4 KiB, backtrace at 20 lines, whole array JSON capped at 64 KiB (drop trailing
209
+ entries and append `{"truncated"=>n}`).
210
+
211
+ ### Redis error mapping
212
+
213
+ `Redis::CannotConnectError`, `Redis::ConnectionError`, `Redis::TimeoutError` are
214
+ wrapped by the *worker/report* into `RedisUnreachable` (exit 2); the queue does not
215
+ retry. Script replies `{"STALE"}`/`{"CORRUPT"}` map to `StaleReservation`/`CorruptBuild`.
216
+
217
+ ## Attempt log
218
+
219
+ `AttemptLog.new(events)` where events are the string-keyed hashes from
220
+ `queue.attempt_events`. `AttemptLog::Event` is a thin wrapper (`type`, `unit_id`,
221
+ `worker_id`, `retry_index`, `reclaim_count`, `ownership_generation`, `at_ms`, `[]`).
222
+
223
+ Queries (all return plain Ruby, deterministic order = first appearance in the log):
224
+
225
+ * `failed` -> `[{unit_id:, reason:, worker_id:, errors:}]` units whose last `finalized` is failed.
226
+ * `flaky` -> unit ids with >= 1 `requeued` and final `finalized` passed.
227
+ * `never_finalized(unit_ids)` -> `[{unit_id:, last_worker_id:}]` for manifest units with no `finalized`; `last_worker_id` from the latest `delivered`/`reclaimed`, or nil.
228
+ * `abandoned` -> unit ids with an `abandoned` event.
229
+ * `retry_counts` -> `{unit_id => max retry_index seen in requeued events}` (only units with >= 1 requeue).
230
+ * `reclaim_counts` -> `{unit_id => count of reclaimed events}` (only units with >= 1).
231
+ * `worker_errors` -> array of the raw `worker_error` events.
232
+ * `stale_rejections` -> raw `stale_rejected` events.
233
+ * `finalized_events(unit_id)` -> array (property spec: exactly one per unit).
234
+ * `to_a` -> events.
235
+
236
+ A unit reclaimed and then passing with no `requeued` event is not flaky.
237
+
238
+ ## Manifest
239
+
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`).
244
+ `#to_meta` -> Hash of String->String for HSET (JSON-encoding the nested fields);
245
+ `Manifest.from_meta(hash)` inverse (ignores the extra runtime fields). `ready_at` is
246
+ epoch ms set by Redis; before publication it's nil.
247
+
248
+ ## Worker (RSpec adapter)
249
+
250
+ `Worker.new(config:, queue_factory:, out: $stdout, err: $stderr)` and `#run` returns an
251
+ exit code (0, 2). `queue_factory` is a lambda returning a `Queue::RedisStreams` so the
252
+ worker can open Redis only after the suite boots (and the supervisor can pass a lambda to
253
+ children). Sequence:
254
+
255
+ 1. `Suite.new(rspec_args, config)`: `ConfigurationOptions.new(args)`; reject `--init`
256
+ from raw argv/`SPEC_OPTS`; `options.options[:bisect]`/`[:drb]` -> `UnsupportedOption`;
257
+ `options.configure(RSpec.configuration)`; then check `configuration.fail_fast`,
258
+ `only_failures?` (covers `--next-failure`), `dry_run?` -> `UnsupportedOption`.
259
+ Register a `:message` listener on `RSpec.configuration.reporter` to capture load
260
+ errors (`Reporter#notify_non_example_exception` emits `message`), then
261
+ `configuration.load_spec_files`; `RSpec.world.wants_to_quit || rspec_is_quitting`
262
+ with captured messages => `load_errors`. Any other exception during boot is phase
263
+ `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`
266
+ 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
268
+ fingerprint are `example.id` over `RSpec.world.all_examples` filtered to the selected
269
+ 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
272
+ strings, pattern:, exclude_pattern:, order: configuration.ordering_registry ... name
273
+ (`RSpec.configuration.ordering_manager` exposes `seed_used?`/`order`; derive the
274
+ strategy name from `configuration.ordering_manager.instance_variable_get`? NO —
275
+ use the merged option `options.options[:order]` string minus any `:seed` suffix, or
276
+ `"defined"` when absent), example_ids: sorted, revision:}`. Also returns the
277
+ `inputs` hash so a mismatch message can diff them (`Fingerprint::Mismatch#explain`).
278
+ 4. Election/join per product spec; `config.init_timeout` bounds it. After `ready`:
279
+ compare fingerprint; `RSpec.configuration.seed = manifest.seed`.
280
+ 5. `RSpec.configuration.reporter.report(total_selected_examples_for_this_worker?)` —
281
+ NO: the outer reporter lifecycle is `reporter.start(expected_count)` with the
282
+ manifest's `total_examples`, `configuration.with_suite_hooks { loop }`, then
283
+ `reporter.finish`. Use `report(n) { ... }` from Runner so `start`/`finish`/`close` fire
284
+ once. The runner subclasses `RSpec::Core::Runner` (`Worker::Runner`) and overrides
285
+ `run_specs` to run the hopper loop; `setup` is reused for configure+load.
286
+ 6. Loop: `queue.reclaim_lost(w) || queue.reserve(w)`; nil -> `touch_liveness`, check
287
+ `complete?` (raise `BuildStateMissing` -> exit 2), check `Process.ppid` if
288
+ supervised, continue. With a reservation: `ExampleReset.reset(groups)` if
289
+ `reservation.ownership_generation > 1` OR the process has run these groups before
290
+ (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)`.
294
+ 7. `RequeuePolicy`: collect `execution_result` of the unit's selected examples; failed
295
+ examples' `exception` (and for `RSpec::Core::MultipleExceptionError`, `all_exceptions`)
296
+ must all be requeueable (`not SystemExit/Interrupt/SignalException/NoMemoryError`);
297
+ `passed?` if no failures. Decision `:passed`, `:requeue_candidate`, `:final_failure`.
298
+ 8. Passed -> `queue.finalize(passed)`, replay buffer. Final failure ->
299
+ `queue.finalize(failed, reason: test_failure, errors:)`, replay. Requeue candidate ->
300
+ `queue.requeue(...)`; `:requeued` -> discard buffer, print
301
+ `Retrying <unit> (retry <i> of <max>; next attempt <i+1>): <n> failure(s)`;
302
+ `:finalized` -> replay buffer. `StaleReservation` -> discard buffer,
303
+ `record_stale_rejected`, continue. `CorruptBuild` -> `record_worker_error(phase:
304
+ redis)` and exit 2.
305
+ 9. Completion: `complete?` true -> exit loop; `reporter.finish` via `report`; return 0.
306
+
307
+ `BufferingReporter` responds to `example_group_started/finished`, `example_started`,
308
+ `example_finished`, `example_passed/failed/pending`, `fail_fast_limit_met?` (false),
309
+ `message`, `publish`, `notify_non_example_exception`, `deprecation` — everything RSpec may
310
+ call on it from `ExampleGroup.run`/`Example#run`; records `[method, args]`; `replay(real)`
311
+ sends each in order; `discard`. It must NOT forward `report`/`start`/`finish`/`close`.
312
+ Note `Example#run` also calls `reporter.example_started(self)` etc. via `start`/`finish`
313
+ — buffer them all. `Reporter#example_finished` is what the profiler listens to;
314
+ `example_failed` increments `@failed_examples` on the real reporter, so a replayed
315
+ failure still fails `dump_summary` — correct.
316
+
317
+ `Heartbeat.new(queue:, reservation:, config:, out:, clock:)`; `#start`/`#stop`; thread:
318
+ every `min(timeout/3, 30)` s call `queue.heartbeat(reservation)` (swallow
319
+ `StaleReservation`: stop heartbeating, flag `stale` so the runner knows the result
320
+ will be rejected — still let the unit finish; the finalize will get STALE anyway).
321
+ A `Redis::BaseConnectionError` from a renewal is retried after `RETRY_INTERVAL` (1 s,
322
+ capped by the interval) instead of killing the thread, until `timeout` has passed since
323
+ the last successful renewal, at which point the entry is reclaimable and the error is
324
+ re-raised (it surfaces through `Thread#join` in `stop` and the worker exits 2). The
325
+ first failure and the recovery each print one line. The same error while recording
326
+ `abandoned` only retries: that event is a warning, and the abort at
327
+ `max_unit_duration + timeout` still fires.
328
+ After `max_unit_duration` elapsed: `queue.record_abandoned(reservation, elapsed_ms:)`
329
+ (one more queue verb; the script mode `abandoned` appends the event, fenced), stop
330
+ renewing; after a further `timeout`: print `Aborting worker: <unit> exceeded <s>s` to
331
+ `err`, flush, `exit!(4)`.
332
+
333
+ `ExampleReset.reset(example_groups)` — for each group and all descendants, for each
334
+ `filtered_examples`: `instance_variable_set(:@exception, nil)`,
335
+ `metadata[:execution_result] = RSpec::Core::Example::ExecutionResult.new`. Pinned
336
+ ivar list after a run under bare rspec-core 3.13.6:
337
+ `[:@clock, :@example_block, :@example_group_class, :@example_group_instance,
338
+ :@exception, :@id, :@metadata, :@reporter]`.
339
+
340
+ ## Report
341
+
342
+ `Report.new(config:, queue:, clock:, sleeper:)`, `#run(out:)` -> exit code; `#summary`
343
+ -> Hash for JSON. Wait phases per product spec. Inactivity: `now - max(ready_at,
344
+ workers.values.map(last_seen).max) > inactive_timeout` -> exit 3. The `--timeout`
345
+ default is 1080 s. JSON summary schema:
346
+
347
+ ```json
348
+ {"build_id": "...", "state": "ready", "verdict": "passed|failed|incomplete|init_failed|missing|expired",
349
+ "exit_code": 0, "total_units": 12, "total_examples": 340, "finalized_count": 12,
350
+ "failed": [{"unit_id":..., "reason":..., "worker_id":..., "errors":[...]}],
351
+ "flaky": ["./spec/..."], "never_finalized": [{"unit_id":..., "last_worker_id":...}],
352
+ "abandoned": ["..."], "retry_counts": {...}, "reclaim_counts": {...},
353
+ "worker_errors": [...], "stale_rejections": 0, "workers": {...}, "load_errors": [...],
354
+ "file_args": [...], "seed": 1234, "fingerprint": "...", "revision": null}
355
+ ```
356
+ The summary also carries a `message` headline, and the verdict `unreachable` (exit 2)
357
+ when Redis cannot be reached. `--failed-out` writes one unit id per line (failed +
358
+ never_finalized).
359
+
360
+ ## Config
361
+
362
+ ```ruby
363
+ WorkConfig = Data.define(:build_id, :worker_id, :redis_url, :timeout, :max_unit_duration,
364
+ :max_requeues, :requeue_tolerance, :max_reclaims, :processes, :boot, :report_on_exit,
365
+ :ttl, :tombstone_ttl, :init_timeout, :revision, :rspec_args, :report_args, :supervised)
366
+ ReportConfig = Data.define(:build_id, :redis_url, :timeout, :init_timeout,
367
+ :inactive_timeout, :summary_out, :failed_out, :allow_empty, :min_examples)
368
+ ```
369
+ Defaults live in `Config::DEFAULTS`. `boot` is `:per_process` or `:shared`.
370
+ `rspec_args` is the array after the gem's own flags (everything OptionParser did not
371
+ consume, plus everything after `--`). `report_args` are the raw args for the parent's
372
+ `--report-on-exit` report (built from the work flags: build, redis).
373
+
374
+ Exit codes: `ExitCode::OK = 0, TEST_FAILURE = 1, INFRASTRUCTURE = 2, INCOMPLETE = 3,
375
+ ABORTED = 4` in errors.rb.
376
+
377
+ ## Conventions
378
+
379
+ * rubocop (`.rubocop.yml` at root) must pass: `bundle exec rubocop`. Run it on your
380
+ files before finishing. Line length 120.
381
+ * Specs: `bundle exec rspec spec/rspec/hopper/...`. Redis specs need the test server;
382
+ tag them `:redis` and `spec_helper` skips them with a clear message if the server is
383
+ unreachable.
384
+ * No `Marshal`, no globals, no `RSpec.configuration` mutation outside `Worker::Suite`.
385
+ * Frozen string literals everywhere. Ruby >= 3.2 syntax only (Data.define ok).
386
+ * Do not commit. The integrator commits.
data/exe/rspec-hopper ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec/hopper"
5
+
6
+ exit RSpec::Hopper::CLI.run(ARGV)
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ # Append-only stream of typed lifecycle events, and the queries that run
6
+ # over it. Events are the string-keyed Hashes returned by
7
+ # `queue.attempt_events`, in stream order.
8
+ class AttemptLog
9
+ include Enumerable
10
+
11
+ TYPES = %w[delivered reclaimed requeued abandoned finalized stale_rejected worker_error].freeze
12
+ UNIT_SCOPED_TYPES = (TYPES - %w[worker_error]).freeze
13
+ # Events that record which worker currently holds a unit.
14
+ OWNERSHIP_TYPES = %w[delivered reclaimed].freeze
15
+
16
+ # Thin wrapper over one event Hash.
17
+ class Event
18
+ attr_reader :raw
19
+
20
+ def initialize(raw)
21
+ @raw = raw.transform_keys(&:to_s).freeze
22
+ end
23
+
24
+ def type = raw["type"]
25
+ def unit_id = raw["unit_id"]
26
+ def worker_id = raw["worker_id"]
27
+ def retry_index = raw["retry_index"]
28
+ def reclaim_count = raw["reclaim_count"]
29
+ def ownership_generation = raw["ownership_generation"]
30
+ def at_ms = raw["at_ms"]
31
+
32
+ def [](key) = raw[key.to_s]
33
+ def to_h = raw
34
+
35
+ def unit_scoped? = UNIT_SCOPED_TYPES.include?(type) && !unit_id.nil?
36
+ def finalized? = type == "finalized"
37
+ def passed? = finalized? && raw["outcome"] == "passed"
38
+ def failed? = finalized? && raw["outcome"] == "failed"
39
+
40
+ # The derived-counter invariant every unit-scoped event must satisfy.
41
+ def consistent_generation?
42
+ ownership_generation == 1 + retry_index.to_i + reclaim_count.to_i
43
+ end
44
+ end
45
+
46
+ attr_reader :events
47
+
48
+ def initialize(events)
49
+ @events = events.map { |e| e.is_a?(Event) ? e : Event.new(e) }.freeze
50
+ end
51
+
52
+ def each(&) = events.each(&)
53
+ def to_a = events.map(&:to_h)
54
+ def size = events.size
55
+ def empty? = events.empty?
56
+
57
+ # Unit ids in order of first appearance in the log.
58
+ def unit_ids = by_unit.keys
59
+
60
+ # Units whose last `finalized` event is failed.
61
+ def failed
62
+ by_unit.filter_map do |unit_id, unit_events|
63
+ last = last_finalized(unit_events)
64
+ next unless last&.failed?
65
+
66
+ { unit_id: unit_id, reason: last["reason"], worker_id: last.worker_id, errors: last["errors"] || [] }
67
+ end
68
+ end
69
+
70
+ # Units with at least one `requeued` event whose final outcome is passed.
71
+ # A unit reclaimed after worker death and then passing is not flaky.
72
+ def flaky
73
+ by_unit.filter_map do |unit_id, unit_events|
74
+ unit_id if unit_events.any? { |e| e.type == "requeued" } && last_finalized(unit_events)&.passed?
75
+ end
76
+ end
77
+
78
+ # Manifest units with no `finalized` event, with the worker that last held
79
+ # each (from its latest `delivered` or `reclaimed` event).
80
+ def never_finalized(unit_ids)
81
+ unit_ids.filter_map do |unit_id|
82
+ unit_events = by_unit.fetch(unit_id, [])
83
+ next if unit_events.any?(&:finalized?)
84
+
85
+ holder = unit_events.reverse.find { |e| OWNERSHIP_TYPES.include?(e.type) }
86
+ { unit_id: unit_id, last_worker_id: holder&.worker_id }
87
+ end
88
+ end
89
+
90
+ def abandoned
91
+ by_unit.filter_map { |unit_id, unit_events| unit_id if unit_events.any? { |e| e.type == "abandoned" } }
92
+ end
93
+
94
+ # unit id -> highest retry_index seen in its `requeued` events.
95
+ def retry_counts
96
+ by_unit.filter_map do |unit_id, unit_events|
97
+ requeues = unit_events.select { |e| e.type == "requeued" }
98
+ [unit_id, requeues.map { |e| e.retry_index.to_i }.max] if requeues.any?
99
+ end.to_h
100
+ end
101
+
102
+ # unit id -> number of `reclaimed` events.
103
+ def reclaim_counts
104
+ by_unit.filter_map do |unit_id, unit_events|
105
+ count = unit_events.count { |e| e.type == "reclaimed" }
106
+ [unit_id, count] if count.positive?
107
+ end.to_h
108
+ end
109
+
110
+ def worker_errors = raw_of_type("worker_error")
111
+ def stale_rejections = raw_of_type("stale_rejected")
112
+
113
+ def finalized_events(unit_id)
114
+ by_unit.fetch(unit_id, []).select(&:finalized?).map(&:to_h)
115
+ end
116
+
117
+ def exactly_one_finalized?(unit_ids)
118
+ unit_ids.all? { |unit_id| finalized_events(unit_id).size == 1 }
119
+ end
120
+
121
+ private
122
+
123
+ def raw_of_type(type) = events.select { |e| e.type == type }.map(&:to_h)
124
+
125
+ def last_finalized(unit_events) = unit_events.reverse.find(&:finalized?)
126
+
127
+ # Unit-scoped events grouped by unit, keys in first-appearance order.
128
+ def by_unit
129
+ @by_unit ||= events.select(&:unit_scoped?).group_by(&:unit_id).freeze
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RSpec
6
+ module Hopper
7
+ # Infers a build id and worker id from common CI environment variables when
8
+ # `--build`/`--worker` are omitted. This is the gem's only CI-vendor
9
+ # awareness. The gem's own variables (HOPPER_BUILD_ID, HOPPER_WORKER_ID) are
10
+ # consulted first by the CLI and always win.
11
+ module CIEnv
12
+ Vendor = Data.define(:name, :detect, :build_vars, :worker_vars, :build_id, :worker_id)
13
+
14
+ # Vendor table. `build_vars`/`worker_vars` document what is read; the
15
+ # lambdas derive the ids. `detect` names the variable whose presence
16
+ # identifies the vendor.
17
+ VENDORS = [
18
+ Vendor.new(
19
+ name: "CircleCI", detect: "CIRCLECI",
20
+ build_vars: %w[CIRCLE_WORKFLOW_ID CIRCLE_BUILD_NUM], worker_vars: %w[CIRCLE_NODE_INDEX],
21
+ build_id: ->(env) { present(env["CIRCLE_WORKFLOW_ID"]) || present(env["CIRCLE_BUILD_NUM"]) },
22
+ worker_id: ->(env) { present(env["CIRCLE_NODE_INDEX"]) }
23
+ ),
24
+ Vendor.new(
25
+ name: "Buildkite", detect: "BUILDKITE",
26
+ build_vars: %w[BUILDKITE_BUILD_ID], worker_vars: %w[BUILDKITE_PARALLEL_JOB],
27
+ build_id: ->(env) { present(env["BUILDKITE_BUILD_ID"]) },
28
+ worker_id: ->(env) { present(env["BUILDKITE_PARALLEL_JOB"]) }
29
+ ),
30
+ Vendor.new(
31
+ name: "GitHub Actions", detect: "GITHUB_ACTIONS",
32
+ build_vars: %w[GITHUB_RUN_ID GITHUB_RUN_ATTEMPT], worker_vars: [],
33
+ build_id: lambda { |env|
34
+ run = present(env["GITHUB_RUN_ID"]) or next nil
35
+ "#{run}-#{present(env["GITHUB_RUN_ATTEMPT"]) || "1"}"
36
+ },
37
+ worker_id: ->(_env) {}
38
+ ),
39
+ Vendor.new(
40
+ name: "GitLab CI", detect: "GITLAB_CI",
41
+ build_vars: %w[CI_PIPELINE_ID], worker_vars: %w[CI_NODE_INDEX],
42
+ build_id: ->(env) { present(env["CI_PIPELINE_ID"]) },
43
+ worker_id: ->(env) { present(env["CI_NODE_INDEX"]) }
44
+ )
45
+ ].freeze
46
+
47
+ # Documentation table for the README: vendor name, detection variable,
48
+ # build-id variables, worker-id variables.
49
+ VARIABLES = VENDORS.map { |v| [v.name, v.detect, v.build_vars, v.worker_vars] }.freeze
50
+
51
+ module_function
52
+
53
+ # @return [String, nil] the inferred build id, or nil when no supported
54
+ # CI vendor is detected.
55
+ def build_id(env = ENV)
56
+ vendor = detect(env) or return nil
57
+ vendor.build_id.call(env)
58
+ end
59
+
60
+ # @return [String, nil] the inferred worker id, or nil when the vendor
61
+ # has no standard parallel-index variable (GitHub Actions) or no vendor
62
+ # is detected. Callers fall back to {default_worker_id}.
63
+ def worker_id(env = ENV)
64
+ vendor = detect(env) or return nil
65
+ vendor.worker_id.call(env)
66
+ end
67
+
68
+ # @return [Vendor, nil]
69
+ def detect(env = ENV)
70
+ VENDORS.find { |v| present(env[v.detect]) }
71
+ end
72
+
73
+ # Unique enough for one machine at one moment; used when neither a flag,
74
+ # HOPPER_WORKER_ID, nor a CI variable names the worker.
75
+ def default_worker_id(hostname: Socket.gethostname, pid: Process.pid)
76
+ "#{hostname}-#{pid}"
77
+ end
78
+
79
+ def present(value)
80
+ return nil if value.nil?
81
+
82
+ value = value.to_s.strip
83
+ value.empty? ? nil : value
84
+ end
85
+ end
86
+ end
87
+ end