ci-queue 0.96.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c3ced29dc916918cd28d47f312be55e014fd5836ab0c91d1e2eab17c9484951
4
- data.tar.gz: 3ac890363a4a861ca2c3420c24490dff1ffd89dfb890b8d0fd71062ccf812003
3
+ metadata.gz: 020b8bf0b6887aa814e3f6b310bc62888e9a5e0af94c6ed652efc0af2b947ef7
4
+ data.tar.gz: 11e86bbe7b3e6bacfe96b84ae131c40222fb61a1afa9788f953c3a85db02d751
5
5
  SHA512:
6
- metadata.gz: 27f198e8d9201f56112cc29bb3fcaa8518e7d5ddedc629d5eba13de06e0b936a8e809d47162d088f57d5ff614d3c4f92f003f786de62310ae421498e9913d8a9
7
- data.tar.gz: 2c5f925d28012585afa8212cbb14afb1966cb0a2e5dbeaa9927486b2e96fb2c96d595f06ef7aa5ab6b97e5c238e5bca01e3572273a8a5fa9e8be357f54c44bf5
6
+ metadata.gz: b02b9a927e82321c3792c5a479487ef2a80e1093e23d4614e1e3b9191b9a5b5f9691cbdc78bd2a2a566e97dcbcf6231c3d9df19521bc2408099b6e905c38ecfa
7
+ data.tar.gz: 39cbdb4ef28553b81373f5836a8267b1919815fcab5959893bf002a307b7341e6337e045b00dd8a06e4c93dba2a1b3bd05ca9b96672292b5e25b8f50cc507fbf
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ci-queue (0.96.0)
4
+ ci-queue (0.98.0)
5
5
  logger
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -36,7 +36,7 @@ Assuming you use one of the supported CI providers, the command can be as simple
36
36
  minitest-queue --queue redis://example.com run -Itest test/**/*_test.rb
37
37
  ```
38
38
 
39
- Additionally you can configure the requeue settings (see main README) with `--max-requeues` and `--requeue-tolerance`.
39
+ Additionally you can configure the requeue settings (see main README) with `--max-requeues` and `--requeue-tolerance`. Worker-health circuit breakers are available through `--max-consecutive-failures` and `--max-consecutive-requeues`.
40
40
 
41
41
  #### Lazy loading (opt-in)
42
42
 
@@ -138,6 +138,57 @@ The runner also comes with a tool to investigate leaky tests:
138
138
  minitest-queue --queue path/to/test_order.log --failing-test 'SomeTest#test_something' bisect -Itest test/**/*_test.rb
139
139
  ```
140
140
 
141
+ #### Parallel worker metadata
142
+
143
+ Each result is stamped, as it is recorded in the process that ran the test, with:
144
+
145
+ - `parallel_worker_pid`: the pid of the process that ran the test.
146
+ - `parallel_worker_test_index`: a 0-based monotonic counter of results recorded by that process (requeued executions get their own index). Restarts at 0 in each forked process.
147
+ - `parallel_worker_id`: an identifier injected by the embedding environment (e.g. a Rails parallel-testing worker number); `nil` when not applicable.
148
+
149
+ All three fields are included (nil-safe) in `log/test_data.json` emitted by the test data reporter, making per-worker-process execution order reconstructable downstream (`PARTITION BY parallel_worker_id, parallel_worker_pid ORDER BY parallel_worker_test_index`).
150
+
151
+ The worker id can be provided either programmatically or via the environment:
152
+
153
+ ```ruby
154
+ Minitest::Queue.parallel_worker_id = 3
155
+ ```
156
+
157
+ | Variable | Description |
158
+ |---|---|
159
+ | `CI_QUEUE_PARALLEL_WORKER_ID=N` | Sets `parallel_worker_id` for results produced by this process. Read once per process; the setter takes precedence. |
160
+
161
+ Stamping is first-writer-wins. When ci-queue runs tests in-process (the normal
162
+ `minitest-queue` flow), results are stamped automatically as they are recorded.
163
+ Embedding environments that run tests in forked workers and transport results to
164
+ another process (e.g. over DRb to a central reporting server) must stamp in the
165
+ worker **before** sending:
166
+
167
+ ```ruby
168
+ # in the forked worker, after running the test and before the DRb send
169
+ Minitest::Queue.stamp_parallel_worker_metadata(result)
170
+ ```
171
+
172
+ Otherwise the automatic stamp during reporting would capture the reporting
173
+ process's pid and an arrival-order index interleaved across workers. Pre-stamped
174
+ results pass through reporting untouched.
175
+
176
+ Notes:
177
+
178
+ - `parallel_worker_id` identifies a forked test process *inside* one queue worker.
179
+ It is unrelated to ci-queue's own `--worker` / `CI::Queue::Configuration#worker_id`,
180
+ which identifies the whole queue worker (typically one CI job).
181
+ - The payload intentionally carries no build/job identity — like every other field
182
+ in `log/test_data.json`, scoping to a build/job (e.g. a `job_id` column making
183
+ `(job_id, parallel_worker_id, parallel_worker_pid)` unique across a build) is
184
+ expected to be attached by whatever pipeline ingests the file.
185
+ - Stamping is mutex-guarded, so indexes are unique and gap-free even when results
186
+ are recorded from multiple threads. However, per-worker order reconstruction is
187
+ only meaningful with process-based (forked) workers: under thread-based
188
+ parallelization all threads share one `(parallel_worker_id, parallel_worker_pid)`
189
+ partition and the index reflects record order across threads.
190
+ - This applies to minitest-queue only; rspec-queue does not emit these fields.
191
+
141
192
  ### RSpec [DEPRECATED]
142
193
 
143
194
  The rspec-queue runner is deprecated. The minitest-queue runner continues to be supported and is actively being improved. At Shopify, we strongly recommend that new projects set up their test suite using Minitest rather than RSpec.
@@ -158,6 +209,30 @@ rspec-queue --queue redis://example.com --timeout 600 --report
158
209
 
159
210
  Because of how `ci-queue` executes the examples, `before(:all)` and `after(:all)` hooks are not supported. `rspec-queue` will explicitly reject them.
160
211
 
212
+ ### Worker circuit breakers
213
+
214
+ Both runners support two independent, disabled-by-default circuit breakers:
215
+
216
+ - `--max-consecutive-failures MAX` stops a worker after `MAX` consecutive final test failures.
217
+ - `--max-consecutive-requeues MAX` stops a worker after `MAX` consecutive failures were accepted for requeueing.
218
+
219
+ The requeue count is local to each worker and resets whenever that worker produces any non-requeued result, including a pass, skip, ignored flaky result, or final failure. An accepted requeue neither increments nor resets the consecutive-final-failure count. Requeue attempts rejected because of an ownership race or an exhausted retry budget follow the final-failure path and do not increment the requeue count.
220
+
221
+ For example:
222
+
223
+ ```bash
224
+ minitest-queue --max-consecutive-requeues 10 --queue redis://example.com run test/**/*_test.rb
225
+ rspec-queue --max-consecutive-requeues 10 --queue redis://example.com
226
+ ```
227
+
228
+ The breaker can also be configured programmatically:
229
+
230
+ ```ruby
231
+ CI::Queue::Configuration.new(max_consecutive_requeues: 10)
232
+ # or
233
+ config.max_consecutive_requeues = 10
234
+ ```
235
+
161
236
  ## Releasing a New Version
162
237
 
163
238
  After merging changes to `main`, follow these steps to release and propagate the update:
@@ -8,6 +8,9 @@ module CI
8
8
  def report_failure!
9
9
  end
10
10
 
11
+ def report_requeue!
12
+ end
13
+
11
14
  def report_success!
12
15
  end
13
16
 
@@ -32,6 +35,9 @@ module CI
32
35
  def report_failure!
33
36
  end
34
37
 
38
+ def report_requeue!
39
+ end
40
+
35
41
  def report_success!
36
42
  end
37
43
 
@@ -60,6 +66,9 @@ module CI
60
66
  @consecutive_failures += 1
61
67
  end
62
68
 
69
+ def report_requeue!
70
+ end
71
+
63
72
  def report_success!
64
73
  @consecutive_failures = 0
65
74
  end
@@ -72,6 +81,33 @@ module CI
72
81
  'This worker is exiting early because it encountered too many consecutive test failures, probably because of some corrupted state.'
73
82
  end
74
83
  end
84
+
85
+ class MaxConsecutiveRequeues
86
+ def initialize(max_consecutive_requeues:)
87
+ @max = max_consecutive_requeues
88
+ @consecutive_requeues = 0
89
+ end
90
+
91
+ def report_failure!
92
+ @consecutive_requeues = 0
93
+ end
94
+
95
+ def report_requeue!
96
+ @consecutive_requeues += 1
97
+ end
98
+
99
+ def report_success!
100
+ @consecutive_requeues = 0
101
+ end
102
+
103
+ def open?
104
+ @consecutive_requeues >= @max
105
+ end
106
+
107
+ def message
108
+ 'This worker is exiting early because it requeued too many consecutive tests, probably because of some corrupted state.'
109
+ end
110
+ end
75
111
  end
76
112
  end
77
113
  end
@@ -27,6 +27,10 @@ module CI
27
27
  config.circuit_breakers.each(&:report_failure!)
28
28
  end
29
29
 
30
+ def report_requeue!
31
+ config.circuit_breakers.each(&:report_requeue!)
32
+ end
33
+
30
34
  def report_success!
31
35
  config.circuit_breakers.each(&:report_success!)
32
36
  end
@@ -55,7 +55,7 @@ module CI
55
55
  def initialize(
56
56
  timeout: 30, build_id: nil, worker_id: nil, max_requeues: 0, requeue_tolerance: 0,
57
57
  namespace: nil, seed: nil, flaky_tests: [], statsd_endpoint: nil, max_consecutive_failures: nil,
58
- grind_count: nil, max_duration: nil, failure_file: nil, max_test_duration: nil,
58
+ max_consecutive_requeues: nil, grind_count: nil, max_duration: nil, failure_file: nil, max_test_duration: nil,
59
59
  max_test_duration_percentile: 0.5, track_test_duration: false, max_test_failed: nil,
60
60
  queue_init_timeout: nil, redis_ttl: 8 * 60 * 60, report_timeout: nil, inactive_workers_timeout: nil,
61
61
  export_flaky_tests_file: nil, warnings_file: nil, debug_log: nil, max_missed_heartbeat_seconds: nil, heartbeat_max_test_duration: nil,
@@ -79,6 +79,7 @@ module CI
79
79
  @track_test_duration = track_test_duration
80
80
  @worker_id = worker_id
81
81
  self.max_consecutive_failures = max_consecutive_failures
82
+ self.max_consecutive_requeues = max_consecutive_requeues
82
83
  self.max_duration = max_duration
83
84
  @redis_ttl = redis_ttl
84
85
  @report_timeout = report_timeout
@@ -165,6 +166,12 @@ module CI
165
166
  end
166
167
  end
167
168
 
169
+ def max_consecutive_requeues=(max)
170
+ if max
171
+ @circuit_breakers << CircuitBreaker::MaxConsecutiveRequeues.new(max_consecutive_requeues: max)
172
+ end
173
+ end
174
+
168
175
  def max_duration=(duration)
169
176
  if duration
170
177
  @circuit_breakers << CircuitBreaker::Timeout.new(duration: duration)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CI
4
4
  module Queue
5
- VERSION = '0.96.0'
5
+ VERSION = '0.98.0'
6
6
  DEV_SCRIPTS_ROOT = ::File.expand_path('../../../../../redis', __FILE__)
7
7
  RELEASE_SCRIPTS_ROOT = ::File.expand_path('../redis', __FILE__)
8
8
  end
@@ -692,6 +692,15 @@ module Minitest
692
692
  queue_config.max_consecutive_failures = max
693
693
  end
694
694
 
695
+ help = <<~EOS
696
+ Defines after how many consecutive accepted requeues the worker will be considered unhealthy and stop reserving tests.
697
+ Defaults to disabled.
698
+ EOS
699
+ opts.separator ""
700
+ opts.on('--max-consecutive-requeues MAX', Integer, help) do |max|
701
+ queue_config.max_consecutive_requeues = max
702
+ end
703
+
695
704
  help = <<~EOS
696
705
  Must set this option in report and report_grind command if you set --max-test-duration in the report_grind
697
706
  EOS
@@ -49,7 +49,7 @@ module Minitest
49
49
  end
50
50
 
51
51
  def test_duration
52
- @test.time
52
+ @test.time || 0.0
53
53
  end
54
54
 
55
55
  def test_start_timestamp
@@ -73,6 +73,22 @@ module Minitest
73
73
  @test.source_location.last
74
74
  end
75
75
 
76
+ # The parallel_worker_* fields are stamped by
77
+ # Minitest::Queue.stamp_parallel_worker_metadata in the worker process
78
+ # that ran the test. They are nil for embedders that don't produce
79
+ # them (results lacking the accessors, or no worker id configured).
80
+ def parallel_worker_id
81
+ @test.parallel_worker_id if @test.respond_to?(:parallel_worker_id)
82
+ end
83
+
84
+ def parallel_worker_test_index
85
+ @test.parallel_worker_test_index if @test.respond_to?(:parallel_worker_test_index)
86
+ end
87
+
88
+ def parallel_worker_pid
89
+ @test.parallel_worker_pid if @test.respond_to?(:parallel_worker_pid)
90
+ end
91
+
76
92
  # Error class only considers failures wheras the other error fields also consider skips
77
93
  def error_class
78
94
  return nil unless @test.failure
@@ -119,6 +135,9 @@ module Minitest
119
135
  test_finish_timestamp: test_finish_timestamp,
120
136
  test_file_path: test_file_path,
121
137
  test_file_line_number: test_file_line_number,
138
+ parallel_worker_id: parallel_worker_id,
139
+ parallel_worker_test_index: parallel_worker_test_index,
140
+ parallel_worker_pid: parallel_worker_pid,
122
141
  error_class: error_class,
123
142
  error_message: error_message,
124
143
  error_file_path: error_file_path,
@@ -111,10 +111,22 @@ module Minitest
111
111
  attr_accessor :queue_id, :queue_entry
112
112
  end
113
113
 
114
+ # Per-worker-process execution metadata, stamped on each result in the
115
+ # process that ran the test (before any DRb send in embedding
116
+ # environments), so that per-process execution order is reconstructable
117
+ # downstream: PARTITION BY parallel_worker_id, parallel_worker_pid
118
+ # ORDER BY parallel_worker_test_index.
119
+ module ParallelWorkerMetadata
120
+ attr_accessor :parallel_worker_id, :parallel_worker_test_index, :parallel_worker_pid
121
+ end
122
+
114
123
  module Queue
115
124
  extend ::CI::Queue::OutputHelpers
116
125
  attr_writer :run_command_formatter, :project_root
117
126
 
127
+ PARALLEL_WORKER_METADATA_MUTEX = Mutex.new
128
+ private_constant :PARALLEL_WORKER_METADATA_MUTEX
129
+
118
130
  def run_command_formatter
119
131
  @run_command_formatter ||= if defined?(Rails) && defined?(Rails::TestUnitRailtie)
120
132
  RAILS_RUN_COMMAND_FORMATTER
@@ -155,6 +167,60 @@ module Minitest
155
167
  end
156
168
 
157
169
  class << self
170
+ # Identifies which parallel worker this process is (e.g. Rails'
171
+ # parallel testing worker number). Injected by the embedding
172
+ # environment via this setter or the CI_QUEUE_PARALLEL_WORKER_ID
173
+ # environment variable; nil when not applicable.
174
+ attr_writer :parallel_worker_id
175
+
176
+ def parallel_worker_id
177
+ return @parallel_worker_id if @parallel_worker_id
178
+
179
+ # Memoized per process: re-read after a fork, but not on every test.
180
+ if @parallel_worker_id_env_pid != Process.pid
181
+ @parallel_worker_id_env_pid = Process.pid
182
+ @parallel_worker_id_from_env = parallel_worker_id_from_env
183
+ end
184
+ @parallel_worker_id_from_env
185
+ end
186
+
187
+ # Stamps per-process execution metadata on a result. Must be called
188
+ # in the process that ran the test so pid and per-process test index
189
+ # are captured worker-side.
190
+ #
191
+ # First writer wins: results that were already stamped are left
192
+ # untouched. Embedding environments that run tests in forked workers
193
+ # and transport results to another process (e.g. over DRb) MUST call
194
+ # this in the worker before sending, otherwise the stamp applied
195
+ # during reporting would carry the reporting process's pid and an
196
+ # arrival-order index interleaved across workers.
197
+ #
198
+ # The counter is mutex-guarded: results recorded from multiple threads
199
+ # (e.g. a DRb server dispatching each call on its own thread) get
200
+ # unique, gap-free indexes reflecting record order in this process.
201
+ # Returns the result when it was stamped, nil when it was skipped.
202
+ def stamp_parallel_worker_metadata(result)
203
+ return unless result.respond_to?(:parallel_worker_pid=)
204
+ return if result.parallel_worker_pid # already stamped worker-side
205
+
206
+ PARALLEL_WORKER_METADATA_MUTEX.synchronize do
207
+ pid = Process.pid
208
+ if @parallel_worker_metadata_pid != pid
209
+ # Restart the per-process counter in forked workers so that
210
+ # (parallel_worker_id, parallel_worker_pid, parallel_worker_test_index)
211
+ # always reflects execution order within a single process.
212
+ @parallel_worker_metadata_pid = pid
213
+ @parallel_worker_next_test_index = 0
214
+ end
215
+
216
+ result.parallel_worker_id = parallel_worker_id
217
+ result.parallel_worker_pid = pid
218
+ result.parallel_worker_test_index = @parallel_worker_next_test_index
219
+ @parallel_worker_next_test_index += 1
220
+ end
221
+ result
222
+ end
223
+
158
224
  def queue
159
225
  Minitest.queue
160
226
  end
@@ -179,6 +245,8 @@ module Minitest
179
245
  end
180
246
 
181
247
  def handle_test_result(reporter, example, result)
248
+ stamp_parallel_worker_metadata(result)
249
+
182
250
  if result.respond_to?(:queue_id=)
183
251
  result.queue_id = example.id
184
252
  result.queue_entry = example.queue_entry if result.respond_to?(:queue_entry=)
@@ -198,6 +266,7 @@ module Minitest
198
266
  end
199
267
 
200
268
  if failed && CI::Queue.requeueable?(result) && queue.requeue(example.queue_entry)
269
+ queue.report_requeue!
201
270
  result.requeue!
202
271
  if CI::Queue.debug?
203
272
  $stderr.puts "[ci-queue][requeue] test_id=#{example.id} error_class=#{result.failures.first&.class} error=#{result.failures.first&.message&.lines&.first&.chomp}"
@@ -212,6 +281,17 @@ module Minitest
212
281
 
213
282
  private
214
283
 
284
+ def parallel_worker_id_from_env
285
+ value = ENV['CI_QUEUE_PARALLEL_WORKER_ID']
286
+ return nil if value.nil? || value.empty?
287
+
288
+ begin
289
+ Integer(value)
290
+ rescue ArgumentError
291
+ value
292
+ end
293
+ end
294
+
215
295
  def report_load_stats(queue)
216
296
  return unless CI::Queue.debug?
217
297
  return unless queue.respond_to?(:file_loader)
@@ -440,6 +520,7 @@ module Minitest
440
520
  if result
441
521
  result.start_timestamp = start_timestamp
442
522
  result.finish_timestamp = current_timestamp
523
+ result.time ||= (result.finish_timestamp - result.start_timestamp).to_f
443
524
  end
444
525
  end
445
526
 
@@ -576,7 +657,7 @@ module Minitest
576
657
  Queue.run(*args)
577
658
 
578
659
  if queue.config.circuit_breakers.any?(&:open?)
579
- STDERR.puts queue.config.circuit_breakers.map(&:message).join(' ').strip
660
+ STDERR.puts queue.config.circuit_breakers.select(&:open?).map(&:message).join(' ').strip
580
661
  end
581
662
 
582
663
  if queue.max_test_failed?
@@ -607,11 +688,13 @@ if defined? Minitest::Result
607
688
  Minitest::Result.prepend(Minitest::Flakiness)
608
689
  Minitest::Result.prepend(Minitest::WithTimestamps)
609
690
  Minitest::Result.prepend(Minitest::ResultMetadata)
691
+ Minitest::Result.prepend(Minitest::ParallelWorkerMetadata)
610
692
  else
611
693
  Minitest::Test.prepend(Minitest::Requeueing)
612
694
  Minitest::Test.prepend(Minitest::Flakiness)
613
695
  Minitest::Test.prepend(Minitest::WithTimestamps)
614
696
  Minitest::Test.prepend(Minitest::ResultMetadata)
697
+ Minitest::Test.prepend(Minitest::ParallelWorkerMetadata)
615
698
 
616
699
  module MinitestBackwardCompatibility
617
700
  def source_location
data/lib/rspec/queue.rb CHANGED
@@ -158,6 +158,15 @@ module RSpec
158
158
  queue_config.max_consecutive_failures = Integer(max)
159
159
  end
160
160
 
161
+ help = <<~EOS
162
+ Defines after how many consecutive accepted requeues the worker will be considered unhealthy and stop reserving tests.
163
+ Defaults to disabled.
164
+ EOS
165
+ parser.separator ""
166
+ parser.on('--max-consecutive-requeues MAX', *help) do |max|
167
+ queue_config.max_consecutive_requeues = Integer(max)
168
+ end
169
+
161
170
  help = <<~EOS
162
171
  Defines how long the test report remain after the test run, in seconds.
163
172
  Defaults to 28,800 (8 hours)
@@ -215,22 +224,21 @@ module RSpec
215
224
 
216
225
  def finish(reporter, acknowledge: true)
217
226
  if acknowledge && reporter.respond_to?(:requeue)
218
- if @exception
219
- reporter.report_failure!
220
- else
221
- reporter.report_success!
222
- end
223
-
224
227
  if @exception && CI::Queue.requeueable?(@exception) && reporter.requeue
228
+ reporter.report_requeue!
225
229
  reporter.cancel_run!
226
230
  dup.mark_as_requeued!(reporter)
227
231
  return true
232
+ end
233
+
234
+ if @exception
235
+ reporter.report_failure!
228
236
  else
229
- super(reporter)
237
+ reporter.report_success!
230
238
  end
231
- else
232
- super(reporter)
233
239
  end
240
+
241
+ super(reporter)
234
242
  end
235
243
 
236
244
  def reset!
@@ -417,6 +425,10 @@ module RSpec
417
425
  @queue.report_failure!
418
426
  end
419
427
 
428
+ def report_requeue!
429
+ @queue.report_requeue!
430
+ end
431
+
420
432
  def requeue
421
433
  @queue.requeue(@example.queue_entry)
422
434
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ci-queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.96.0
4
+ version: 0.98.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
@@ -319,7 +319,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
319
319
  - !ruby/object:Gem::Version
320
320
  version: '0'
321
321
  requirements: []
322
- rubygems_version: 4.0.11
322
+ rubygems_version: 4.0.16
323
323
  specification_version: 4
324
324
  summary: Distribute tests over many workers using a queue
325
325
  test_files: []