rspecq-instructure 0.8.1 → 0.8.2.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -2
- data/lib/rspecq/formatters/failure_recorder.rb +0 -3
- data/lib/rspecq/queue.rb +111 -17
- data/lib/rspecq/version.rb +1 -1
- data/lib/rspecq/worker.rb +38 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc24b763464c7e4c08422ac93f921cc37dc3d19a099be47a2cbb3a0503993002
|
|
4
|
+
data.tar.gz: 7a31691e1f3736806edb4d1f6fda4e3085d87738cf795e8be5c49a5b7f2dcb70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed0e52a1d7e547c1099fe18941597aafe574d6feac020726d15f7ad6072c31c449e8d392b7a7964fccf5f030469e74fca39f1b1b579ac1e0544306502b4c1778
|
|
7
|
+
data.tar.gz: 1979f5b9851690d2bda1a6469491f9e8b4873225a620b59bd46739b6117c3fdcec5ec26b7bb49f9cc5874e9774fa24f01dddc235ca74331df6e20bad7a3fb814
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Breaking changes are prefixed with a "[BREAKING]" label.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Fixes accumulate under the unreleased heading without touching
|
|
6
|
+
`lib/rspecq/version.rb`. Bumping that file is what publishes, so a batch is cut
|
|
7
|
+
by one deliberate bump: `X.Y.Z.pre.N` for a build to soak in canvas, then
|
|
8
|
+
`X.Y.Z` once it holds. See the header of `.github/workflows/release.yml`.
|
|
9
|
+
|
|
10
|
+
## 0.8.2 (unreleased)
|
|
11
|
+
|
|
12
|
+
- A raise out of the RSpec runner no longer pins a job. The run is wrapped so
|
|
13
|
+
the error is recorded against the job and the job is acknowledged in an
|
|
14
|
+
`ensure`; previously it sat in the running hash until `requeue_lost_job`
|
|
15
|
+
reclaimed it a full `worker_liveness_sec` later, and the worker died with it.
|
|
16
|
+
- Requeuing a flaky example no longer sleeps. The worker used to block 0.5s on
|
|
17
|
+
every requeue so a peer could claim the job first; `reserve_job` now skips a
|
|
18
|
+
job this worker requeued, provided a live peer exists and something else is
|
|
19
|
+
queued. Alone, or as the last job, it runs it rather than delay the retry.
|
|
20
|
+
- A master that dies between winning the election and publishing no longer
|
|
21
|
+
hangs the build. The election is now a lease that expires, so a surviving
|
|
22
|
+
worker stands again instead of every worker blocking until
|
|
23
|
+
`wait_until_published` gives up. The master extends the lease while it
|
|
24
|
+
publishes, and a worker that has lost it will not publish over the new one.
|
|
25
|
+
`--queue-wait-timeout` now waits up to that long plus `worker_liveness_sec`,
|
|
26
|
+
restarting for each new master.
|
|
27
|
+
|
|
28
|
+
## 0.8.1 (2026-09-23)
|
|
6
29
|
|
|
7
30
|
- [BREAKING] The gem is published to rubygems.org as `rspecq-instructure`,
|
|
8
31
|
not to Artifactory as `rspecq`. Change the dependency to
|
|
@@ -13,11 +36,50 @@ Breaking changes are prefixed with a "[BREAKING]" label.
|
|
|
13
36
|
name, so no code has to change: `require "rspecq"`, the `RSpecQ` namespace,
|
|
14
37
|
and the `rspecq` executable all still work as before.
|
|
15
38
|
|
|
16
|
-
## 0.8.0
|
|
39
|
+
## 0.8.0 (2026-09-17)
|
|
40
|
+
|
|
41
|
+
Reconciles the Instructure fork with upstream skroutz and is the first release
|
|
42
|
+
published to Artifactory rather than a git ref.
|
|
17
43
|
|
|
18
44
|
- [BREAKING] Require Ruby >= 3.2 and raise the redis-rb floor to `>= 5.0`
|
|
19
45
|
(cap `< 7.0`). Verified compatible with redis-rb 6.0 / RESP3.
|
|
46
|
+
- New `--tag` option filters the specs a build runs; prefix with `~` to exclude.
|
|
47
|
+
Repeatable, and passed through to rspec.
|
|
48
|
+
- Two-tier job timings. Workers always record per-build timings; the reporter
|
|
49
|
+
promotes them to the global key only with `--update-timings`, and
|
|
50
|
+
`--timings-key` can redirect that promotion. Whole-file timings are
|
|
51
|
+
reconstructed from per-example entries so a split file is still recognised as
|
|
52
|
+
slow. The global key is explicitly persisted after promotion; previously it
|
|
53
|
+
inherited the build key's 24h TTL and expired silently, dropping later builds
|
|
54
|
+
to random ordering.
|
|
55
|
+
- Build outcome (`success`/`failure`) is written to redis when the build
|
|
56
|
+
finishes, so callers can read the result at the source instead of scraping
|
|
57
|
+
output.
|
|
58
|
+
- Lost jobs are tracked and recovered: a job reserved by a worker that died is
|
|
59
|
+
requeued, counted, and reported. The reporter summary now also shows requeue,
|
|
60
|
+
flaky and lost-job counts, and flaky detail carries per-job timing.
|
|
61
|
+
- Flaky failures are recorded in a structured key rather than only printed.
|
|
62
|
+
- Build-time reporting reworked: workers stamp elected-master, queue-ready and
|
|
63
|
+
finished times, and the summary breaks total runtime into time-from-election,
|
|
64
|
+
time-from-queue-ready and total worker execution time.
|
|
65
|
+
- The rerun command printed for a flaky job is trimmed to that job.
|
|
66
|
+
- `--seed` is parsed as base 10, so a leading zero no longer becomes octal, and
|
|
67
|
+
the seed feeds the worker's own RNG as well as rspec's.
|
|
68
|
+
- Workers back off when no job is available instead of spinning.
|
|
69
|
+
- Redis connection settings (timeouts, reconnect backoff) are tunable via CLI
|
|
70
|
+
flags and env vars.
|
|
71
|
+
- Lua scripts run via plain `EVAL` instead of `EVALSHA`. `SCRIPT LOAD` is
|
|
72
|
+
unavailable behind some Redis proxies, and the overhead saved is not worth
|
|
73
|
+
the incompatibility.
|
|
74
|
+
- Fixed `Queue#publish` returning the wrong value, which made the published
|
|
75
|
+
queue size log as `1`.
|
|
76
|
+
- Fixed a race where a build's duration went unrecorded if the reporter
|
|
77
|
+
observed exhaustion before any worker stamped `finished_at`, and a
|
|
78
|
+
string-comparison bug that capped `max_requeues` at single digits.
|
|
79
|
+
- Removed dead code: `requeueable_job?`, its Lua script, and its stats key.
|
|
20
80
|
- `REDIS_GEM` env var pins the redis-rb version in CI (mirrors `RSPEC_CORE`).
|
|
81
|
+
- `RSPECQ_TEST_REDIS_URL` points the test suite at a specific redis, so it no
|
|
82
|
+
longer assumes a default local instance.
|
|
21
83
|
|
|
22
84
|
## 0.7.1 (2021-04-08)
|
|
23
85
|
|
|
@@ -38,10 +38,7 @@ module RSpecQ
|
|
|
38
38
|
msg = presenter.fully_formatted(nil, @colorizer)
|
|
39
39
|
|
|
40
40
|
if @queue.requeue_job(example, @max_requeues, @worker_id)
|
|
41
|
-
# HACK: try to avoid picking the job we just requeued; we want it
|
|
42
|
-
# to be picked up by a different worker
|
|
43
41
|
@queue.record_flaky_failure(notification.example.id, msg)
|
|
44
|
-
sleep 0.5
|
|
45
42
|
return
|
|
46
43
|
end
|
|
47
44
|
|
data/lib/rspecq/queue.rb
CHANGED
|
@@ -16,15 +16,30 @@ module RSpecQ
|
|
|
16
16
|
RESERVE_JOB = <<~LUA.freeze
|
|
17
17
|
local queue = KEYS[1]
|
|
18
18
|
local queue_running = KEYS[2]
|
|
19
|
+
local requeued_job_original_worker = KEYS[3]
|
|
20
|
+
local worker_heartbeats = KEYS[4]
|
|
19
21
|
local worker_id = ARGV[1]
|
|
22
|
+
local time_now = ARGV[2]
|
|
23
|
+
local timeout = ARGV[3]
|
|
20
24
|
|
|
21
25
|
local job = redis.call('lpop', queue)
|
|
22
|
-
if job then
|
|
23
|
-
redis.call('hset', queue_running, worker_id, job)
|
|
24
|
-
return job
|
|
25
|
-
else
|
|
26
|
+
if not job then
|
|
26
27
|
return nil
|
|
27
28
|
end
|
|
29
|
+
|
|
30
|
+
-- A requeue sits at the head to be retried promptly; leave our own for a
|
|
31
|
+
-- live peer. Alone, deferring it only delays the retry (and fail-fast).
|
|
32
|
+
if redis.call('hget', requeued_job_original_worker, job) == worker_id and
|
|
33
|
+
redis.call('zcount', worker_heartbeats, time_now - timeout, '+inf') > 1 then
|
|
34
|
+
local other = redis.call('lpop', queue)
|
|
35
|
+
if other then
|
|
36
|
+
redis.call('lpush', queue, job)
|
|
37
|
+
job = other
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
redis.call('hset', queue_running, worker_id, job)
|
|
42
|
+
return job
|
|
28
43
|
LUA
|
|
29
44
|
|
|
30
45
|
# Scans for dead workers and puts their reserved jobs back to the queue.
|
|
@@ -74,6 +89,37 @@ module RSpecQ
|
|
|
74
89
|
return true
|
|
75
90
|
LUA
|
|
76
91
|
|
|
92
|
+
BECOME_MASTER = <<~LUA.freeze
|
|
93
|
+
local key_queue_status = KEYS[1]
|
|
94
|
+
local key_master_id = KEYS[2]
|
|
95
|
+
local status_initializing = ARGV[1]
|
|
96
|
+
local lease = ARGV[2]
|
|
97
|
+
local worker_id = ARGV[3]
|
|
98
|
+
|
|
99
|
+
if redis.call('set', key_queue_status, status_initializing, 'NX', 'EX', lease) then
|
|
100
|
+
redis.call('set', key_master_id, worker_id)
|
|
101
|
+
return 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return false
|
|
105
|
+
LUA
|
|
106
|
+
|
|
107
|
+
REFRESH_ELECTION_LEASE = <<~LUA.freeze
|
|
108
|
+
local key_queue_status = KEYS[1]
|
|
109
|
+
local key_master_id = KEYS[2]
|
|
110
|
+
local worker_id = ARGV[1]
|
|
111
|
+
local lease = ARGV[2]
|
|
112
|
+
local status_initializing = ARGV[3]
|
|
113
|
+
|
|
114
|
+
-- Once published, status must not expire, even if a refresh races the publish.
|
|
115
|
+
if redis.call('get', key_master_id) == worker_id and
|
|
116
|
+
redis.call('get', key_queue_status) == status_initializing then
|
|
117
|
+
return redis.call('expire', key_queue_status, lease)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return 0
|
|
121
|
+
LUA
|
|
122
|
+
|
|
77
123
|
STATUS_INITIALIZING = "initializing".freeze
|
|
78
124
|
STATUS_READY = "ready".freeze
|
|
79
125
|
STATUS_SUCCESS = "success".freeze
|
|
@@ -102,19 +148,27 @@ module RSpecQ
|
|
|
102
148
|
# NOTE: jobs will be processed from head to tail (lpop)
|
|
103
149
|
def publish(jobs, fail_fast = 0)
|
|
104
150
|
time = current_time
|
|
105
|
-
@redis.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
committed = @redis.watch(key_master_id) do |client|
|
|
152
|
+
master = client.get(key_master_id)
|
|
153
|
+
if master && master != @worker_id
|
|
154
|
+
client.unwatch
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
client.multi do |pipeline|
|
|
159
|
+
pipeline.hset(key_queue_config, "fail_fast", fail_fast)
|
|
160
|
+
pipeline.rpush(key_queue_unprocessed, jobs)
|
|
161
|
+
pipeline.setnx(key_queue_ready_at, time)
|
|
162
|
+
pipeline.set(key_queue_status, STATUS_READY)
|
|
163
|
+
end
|
|
110
164
|
end
|
|
111
165
|
|
|
112
|
-
jobs.size
|
|
166
|
+
jobs.size if committed
|
|
113
167
|
end
|
|
114
168
|
|
|
115
169
|
# Records when a master worker was elected (start of the whole build).
|
|
116
170
|
def mark_elected_master_at
|
|
117
|
-
@redis.
|
|
171
|
+
@redis.setnx(key_elected_master_at, current_time)
|
|
118
172
|
end
|
|
119
173
|
|
|
120
174
|
TRY_MARK_FINISHED = <<~LUA.freeze
|
|
@@ -199,8 +253,10 @@ module RSpecQ
|
|
|
199
253
|
keys: [
|
|
200
254
|
key_queue_unprocessed,
|
|
201
255
|
key_queue_running,
|
|
256
|
+
key("requeued_job_original_worker"),
|
|
257
|
+
key_worker_heartbeats,
|
|
202
258
|
],
|
|
203
|
-
argv: [@worker_id]
|
|
259
|
+
argv: [@worker_id, current_time, @worker_liveness_sec]
|
|
204
260
|
)
|
|
205
261
|
end
|
|
206
262
|
|
|
@@ -373,8 +429,22 @@ module RSpecQ
|
|
|
373
429
|
@redis.hgetall(key_requeues).transform_values(&:to_i)
|
|
374
430
|
end
|
|
375
431
|
|
|
432
|
+
# A lease, not a permanent claim: a master that dies before publishing
|
|
433
|
+
# would otherwise block every other worker until their wait times out.
|
|
376
434
|
def become_master
|
|
377
|
-
|
|
435
|
+
eval_script(
|
|
436
|
+
BECOME_MASTER,
|
|
437
|
+
keys: [key_queue_status, key_master_id],
|
|
438
|
+
argv: [STATUS_INITIALIZING, @worker_liveness_sec, @worker_id]
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def refresh_election_lease
|
|
443
|
+
eval_script(
|
|
444
|
+
REFRESH_ELECTION_LEASE,
|
|
445
|
+
keys: [key_queue_status, key_master_id],
|
|
446
|
+
argv: [@worker_id, @worker_liveness_sec, STATUS_INITIALIZING]
|
|
447
|
+
)
|
|
378
448
|
end
|
|
379
449
|
|
|
380
450
|
# Global timings for scheduling, ordered by execution time desc. Whole-file
|
|
@@ -418,14 +488,33 @@ module RSpecQ
|
|
|
418
488
|
[STATUS_READY, STATUS_SUCCESS, STATUS_FAILURE].include?(@redis.get(key_queue_status))
|
|
419
489
|
end
|
|
420
490
|
|
|
491
|
+
# Each election gets the whole wait plus one lease, so a dead master's lease
|
|
492
|
+
# lapses before its waiters give up, and its replacement isn't left the remainder.
|
|
421
493
|
def wait_until_published(timeout = 30)
|
|
422
|
-
|
|
423
|
-
|
|
494
|
+
limit = timeout + @worker_liveness_sec
|
|
495
|
+
master = deadline = nil
|
|
496
|
+
|
|
497
|
+
loop do
|
|
498
|
+
status = @redis.get(key_queue_status)
|
|
499
|
+
return if [STATUS_READY, STATUS_SUCCESS, STATUS_FAILURE].include?(status)
|
|
500
|
+
|
|
501
|
+
if status.nil? && block_given?
|
|
502
|
+
yield
|
|
503
|
+
next
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
current = @redis.get(key_master_id)
|
|
507
|
+
if deadline.nil? || current != master
|
|
508
|
+
master = current
|
|
509
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + limit
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
513
|
+
raise "Queue not yet published after #{limit} seconds"
|
|
514
|
+
end
|
|
424
515
|
|
|
425
516
|
sleep 0.1
|
|
426
517
|
end
|
|
427
|
-
|
|
428
|
-
raise "Queue not yet published after #{timeout} seconds"
|
|
429
518
|
end
|
|
430
519
|
|
|
431
520
|
def build_successful?
|
|
@@ -480,6 +569,11 @@ module RSpecQ
|
|
|
480
569
|
key("queue", "status")
|
|
481
570
|
end
|
|
482
571
|
|
|
572
|
+
# redis: STRING <worker_id> — holder of the election lease on queue:status
|
|
573
|
+
def key_master_id
|
|
574
|
+
key("queue", "master_id")
|
|
575
|
+
end
|
|
576
|
+
|
|
483
577
|
# redis: HASH<config_key => config_value>
|
|
484
578
|
def key_queue_config
|
|
485
579
|
key("queue", "config")
|
data/lib/rspecq/version.rb
CHANGED
data/lib/rspecq/worker.rb
CHANGED
|
@@ -115,9 +115,10 @@ module RSpecQ
|
|
|
115
115
|
recovered = queue.recover_own_job
|
|
116
116
|
puts "Recovered abandoned job from previous crash: #{recovered}" if recovered
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
queue.wait_until_published(queue_wait_timeout) do
|
|
119
|
+
q_size = try_publish_queue!(queue)
|
|
120
|
+
puts "Published queue (size=#{q_size})" if q_size
|
|
121
|
+
end
|
|
121
122
|
queue.save_worker_seed(@worker_id, seed)
|
|
122
123
|
|
|
123
124
|
# Use `--seed` to deterministically reproduce test failures
|
|
@@ -190,9 +191,18 @@ module RSpecQ
|
|
|
190
191
|
tags.each { |tag| args.push("--tag", tag) }
|
|
191
192
|
opts = RSpec::Core::ConfigurationOptions.new(args)
|
|
192
193
|
|
|
193
|
-
|
|
194
|
+
begin
|
|
195
|
+
RSpec::Core::Runner.new(opts).run($stderr, $stdout)
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
message = "#{e.class}: #{e.message}\n#{Array(e.backtrace).join("\n")}"
|
|
198
|
+
puts "Error running #{job}: #{message}"
|
|
199
|
+
queue.record_non_example_error(job, message)
|
|
200
|
+
ensure
|
|
201
|
+
# Without this the job stays pinned in the running hash until
|
|
202
|
+
# requeue_lost_job reclaims it, a full worker_liveness_sec later.
|
|
203
|
+
queue.acknowledge_job(job)
|
|
204
|
+
end
|
|
194
205
|
|
|
195
|
-
queue.acknowledge_job(job)
|
|
196
206
|
idx += 1
|
|
197
207
|
end
|
|
198
208
|
end
|
|
@@ -214,7 +224,30 @@ module RSpecQ
|
|
|
214
224
|
return if !queue.become_master
|
|
215
225
|
|
|
216
226
|
queue.mark_elected_master_at
|
|
227
|
+
stop = Thread::Queue.new
|
|
228
|
+
refresher = start_lease_refresher(queue, stop)
|
|
229
|
+
|
|
230
|
+
begin
|
|
231
|
+
publish_queue!(queue)
|
|
232
|
+
ensure
|
|
233
|
+
stop << true
|
|
234
|
+
refresher.join
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def start_lease_refresher(queue, stop)
|
|
239
|
+
Thread.new do
|
|
240
|
+
until stop.pop(timeout: [@heartbeat_frequency, 1].max)
|
|
241
|
+
begin
|
|
242
|
+
queue.refresh_election_lease
|
|
243
|
+
rescue StandardError => e
|
|
244
|
+
puts "Failed to refresh election lease: #{e.message}"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
217
249
|
|
|
250
|
+
def publish_queue!(queue)
|
|
218
251
|
if reproduction
|
|
219
252
|
q_size = queue.publish(files_or_dirs_to_run, fail_fast)
|
|
220
253
|
log_event(
|