rspec-hopper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- metadata +137 -0
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,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
|