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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1df575298a210ef26b7f057dcfd500287519e2ec6f8d32b2296f6eea26bc05fc
4
- data.tar.gz: fe66df1684e18af2501fb67cf26ce475a9db89e65717f83e0ff1f3bb9455041c
3
+ metadata.gz: dc24b763464c7e4c08422ac93f921cc37dc3d19a099be47a2cbb3a0503993002
4
+ data.tar.gz: 7a31691e1f3736806edb4d1f6fda4e3085d87738cf795e8be5c49a5b7f2dcb70
5
5
  SHA512:
6
- metadata.gz: 52b16ee95c80f6a7da5246c6335910dde1a897d7ce37ac7d5f99b508aa306d3cdce350d47fc7d641d1c4947f894345eea3d49844df04aab5aa0ae7de97c51bc1
7
- data.tar.gz: 31ec697c4f6b54c39a8dad7527d6151e177e45a81ad55e74e6a07ad459ae3c430bf02d35a8674817488b9e56008941875e371e73a293d69e8646ac446aefc7b2
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
- ## 0.8.1 (unreleased)
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.instructure2 (unreleased)
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.multi do |pipeline|
106
- pipeline.hset(key_queue_config, "fail_fast", fail_fast)
107
- pipeline.rpush(key_queue_unprocessed, jobs)
108
- pipeline.setnx(key_queue_ready_at, time)
109
- pipeline.set(key_queue_status, STATUS_READY)
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.set(key_elected_master_at, current_time)
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
- @redis.setnx(key_queue_status, STATUS_INITIALIZING)
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
- (timeout * 10).times do
423
- return if published?
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")
@@ -1,3 +1,3 @@
1
1
  module RSpecQ
2
- VERSION = "0.8.1".freeze
2
+ VERSION = "0.8.2.pre.1".freeze
3
3
  end
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
- q_size = try_publish_queue!(queue)
119
- puts "Published queue (size=#{q_size})" if q_size
120
- queue.wait_until_published(queue_wait_timeout)
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
- _result = RSpec::Core::Runner.new(opts).run($stderr, $stdout)
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(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspecq-instructure
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.2.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Agis Anastasopoulos