ci-queue 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile.lock +59 -47
  4. data/README.md +87 -0
  5. data/ci-queue.gemspec +3 -1
  6. data/lib/ci/queue/build_record.rb +5 -5
  7. data/lib/ci/queue/class_resolver.rb +38 -0
  8. data/lib/ci/queue/configuration.rb +62 -1
  9. data/lib/ci/queue/file_loader.rb +101 -0
  10. data/lib/ci/queue/queue_entry.rb +48 -0
  11. data/lib/ci/queue/redis/acknowledge.lua +7 -5
  12. data/lib/ci/queue/redis/base.rb +29 -6
  13. data/lib/ci/queue/redis/build_record.rb +29 -17
  14. data/lib/ci/queue/redis/heartbeat.lua +4 -4
  15. data/lib/ci/queue/redis/monitor.rb +14 -2
  16. data/lib/ci/queue/redis/requeue.lua +17 -10
  17. data/lib/ci/queue/redis/reserve.lua +47 -8
  18. data/lib/ci/queue/redis/supervisor.rb +3 -3
  19. data/lib/ci/queue/redis/worker.rb +210 -27
  20. data/lib/ci/queue/static.rb +5 -5
  21. data/lib/ci/queue/version.rb +1 -1
  22. data/lib/ci/queue.rb +27 -0
  23. data/lib/minitest/queue/build_status_recorder.rb +4 -4
  24. data/lib/minitest/queue/junit_reporter.rb +2 -2
  25. data/lib/minitest/queue/lazy_entry_resolver.rb +55 -0
  26. data/lib/minitest/queue/lazy_test_discovery.rb +169 -0
  27. data/lib/minitest/queue/local_requeue_reporter.rb +11 -0
  28. data/lib/minitest/queue/order_reporter.rb +9 -2
  29. data/lib/minitest/queue/queue_population_strategy.rb +176 -0
  30. data/lib/minitest/queue/runner.rb +97 -22
  31. data/lib/minitest/queue/test_data.rb +15 -2
  32. data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
  33. data/lib/minitest/queue.rb +278 -10
  34. data/lib/rspec/queue/build_status_recorder.rb +4 -2
  35. data/lib/rspec/queue.rb +6 -2
  36. metadata +38 -3
@@ -2,6 +2,7 @@
2
2
  require 'shellwords'
3
3
  require 'minitest'
4
4
  require 'minitest/reporters'
5
+ require 'concurrent/map'
5
6
 
6
7
  require 'minitest/queue/failure_formatter'
7
8
  require 'minitest/queue/error_report'
@@ -106,6 +107,10 @@ module Minitest
106
107
  attr_accessor :start_timestamp, :finish_timestamp
107
108
  end
108
109
 
110
+ module ResultMetadata
111
+ attr_accessor :queue_id, :queue_entry
112
+ end
113
+
109
114
  module Queue
110
115
  extend ::CI::Queue::OutputHelpers
111
116
  attr_writer :run_command_formatter, :project_root
@@ -156,19 +161,29 @@ module Minitest
156
161
 
157
162
  def run(reporter, *)
158
163
  rescue_run_errors do
159
- queue.poll do |example|
160
- result = queue.with_heartbeat(example.id) do
161
- example.run
164
+ begin
165
+ queue.poll do |example|
166
+ result = queue.with_heartbeat(example.queue_entry) do
167
+ example.run
168
+ end
169
+
170
+ handle_test_result(reporter, example, result)
162
171
  end
163
172
 
164
- handle_test_result(reporter, example, result)
173
+ report_load_stats(queue)
174
+ ensure
175
+ store_worker_profile(queue)
176
+ queue.stop_heartbeat!
165
177
  end
166
-
167
- queue.stop_heartbeat!
168
178
  end
169
179
  end
170
180
 
171
181
  def handle_test_result(reporter, example, result)
182
+ if result.respond_to?(:queue_id=)
183
+ result.queue_id = example.id
184
+ result.queue_entry = example.queue_entry if result.respond_to?(:queue_entry=)
185
+ end
186
+
172
187
  failed = !(result.passed? || result.skipped?)
173
188
 
174
189
  if example.flaky?
@@ -180,20 +195,102 @@ module Minitest
180
195
  # When we do a bisect, we don't care about the result other than the test we're running the bisect on
181
196
  result.mark_as_flaked!
182
197
  failed = false
198
+ end
199
+
200
+ if failed && CI::Queue.requeueable?(result) && queue.requeue(example.queue_entry)
201
+ result.requeue!
202
+ if CI::Queue.debug?
203
+ $stderr.puts "[ci-queue][requeue] test_id=#{example.id} error_class=#{result.failures.first&.class} error=#{result.failures.first&.message&.lines&.first&.chomp}"
204
+ end
183
205
  elsif failed
184
206
  queue.report_failure!
185
207
  else
186
208
  queue.report_success!
187
209
  end
188
-
189
- if failed && CI::Queue.requeueable?(result) && queue.requeue(example)
190
- result.requeue!
191
- end
192
210
  reporter.record(result)
193
211
  end
194
212
 
195
213
  private
196
214
 
215
+ def report_load_stats(queue)
216
+ return unless CI::Queue.debug?
217
+ return unless queue.respond_to?(:file_loader)
218
+ return unless queue.respond_to?(:config) && queue.config.lazy_load
219
+
220
+ loader = queue.file_loader
221
+ return if loader.load_stats.empty?
222
+
223
+ total_time = loader.total_load_time
224
+ file_count = loader.load_stats.size
225
+ average = file_count.zero? ? 0 : (total_time / file_count)
226
+
227
+ puts
228
+ puts "File loading stats:"
229
+ puts " Total time: #{total_time.round(2)}s"
230
+ puts " Files loaded: #{file_count}"
231
+ puts " Average: #{average.round(3)}s per file"
232
+
233
+ slowest = loader.slowest_files(5)
234
+ return if slowest.empty?
235
+
236
+ puts " Slowest files:"
237
+ slowest.each do |file_path, duration|
238
+ puts " #{duration.round(3)}s - #{Minitest::Queue.relative_path(file_path)}"
239
+ end
240
+ end
241
+
242
+ def store_worker_profile(queue)
243
+ debug = CI::Queue.debug?
244
+ return unless queue.respond_to?(:config)
245
+ config = queue.config
246
+
247
+ run_start = Minitest::Queue::Runner.run_start
248
+ return unless run_start
249
+
250
+ run_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
+ profile = {
252
+ 'worker_id' => config.worker_id,
253
+ 'mode' => config.lazy_load ? 'lazy' : 'eager',
254
+ 'role' => queue.master? ? 'leader' : 'non-leader',
255
+ 'total_wall_clock' => (run_end - run_start).round(2),
256
+ }
257
+
258
+ first_test = queue.respond_to?(:first_reserve_at) ? queue.first_reserve_at : nil
259
+ profile['time_to_first_test'] = (first_test - run_start).round(2) if first_test
260
+
261
+ tests_run = queue.rescue_connection_errors { queue.worker_queue_length } if queue.respond_to?(:worker_queue_length)
262
+ profile['tests_run'] = tests_run.to_i if tests_run
263
+
264
+ load_tests_duration = Minitest::Queue::Runner.load_tests_duration
265
+ profile['load_tests_duration'] = load_tests_duration.round(2) if load_tests_duration
266
+
267
+ if queue.respond_to?(:file_loader) && queue.file_loader.load_stats.any?
268
+ loader = queue.file_loader
269
+ profile['files_loaded'] = loader.load_stats.size
270
+ profile['file_load_time'] = loader.total_load_time.round(2)
271
+ end
272
+
273
+ profile['total_files'] = Minitest::Queue::Runner.total_files if Minitest::Queue::Runner.total_files
274
+
275
+ rss_kb = begin
276
+ if File.exist?("/proc/#{Process.pid}/statm")
277
+ pages = Integer(File.read("/proc/#{Process.pid}/statm").split[1])
278
+ pages * 4
279
+ else
280
+ Integer(`ps -o rss= -p #{Process.pid}`.strip)
281
+ end
282
+ rescue
283
+ nil
284
+ end
285
+ profile['memory_rss_kb'] = rss_kb if rss_kb
286
+
287
+ queue.rescue_connection_errors do
288
+ queue.build.record_worker_profile(profile)
289
+ end
290
+ rescue => e
291
+ puts "WARNING: Failed to store worker profile: #{e.message}" if debug
292
+ end
293
+
197
294
  def rescue_run_errors(&block)
198
295
  block.call
199
296
  rescue Errno::EPIPE
@@ -232,6 +329,10 @@ module Minitest
232
329
  @id ||= "#{@runnable}##{@method_name}".freeze
233
330
  end
234
331
 
332
+ def queue_entry
333
+ @queue_entry ||= CI::Queue::QueueEntry.format(id, nil)
334
+ end
335
+
235
336
  def <=>(other)
236
337
  id <=> other.id
237
338
  end
@@ -270,6 +371,171 @@ module Minitest
270
371
  end
271
372
  end
272
373
 
374
+ class LazySingleExample
375
+ attr_reader :class_name, :method_name, :file_path
376
+
377
+ def initialize(class_name, method_name, file_path, loader:, resolver:, load_error: nil, queue_entry: nil)
378
+ @class_name = class_name
379
+ @method_name = method_name
380
+ @file_path = file_path
381
+ @loader = loader
382
+ @resolver = resolver
383
+ @load_error = load_error
384
+ @queue_entry_override = queue_entry
385
+ @runnable = nil
386
+ end
387
+
388
+ def id
389
+ @id ||= "#{@class_name}##{@method_name}".freeze
390
+ end
391
+
392
+ def queue_entry
393
+ @queue_entry ||= @queue_entry_override || CI::Queue::QueueEntry.format(id, file_path)
394
+ end
395
+
396
+ def <=>(other)
397
+ id <=> other.id
398
+ end
399
+
400
+ RUNNABLE_METHODS_TRIGGERED = Concurrent::Map.new # :nodoc:
401
+
402
+ def runnable
403
+ @runnable ||= begin
404
+ klass = @resolver.resolve(@class_name, file_path: @file_path, loader: @loader)
405
+ unless RUNNABLE_METHODS_TRIGGERED[klass]
406
+ klass.runnable_methods
407
+ RUNNABLE_METHODS_TRIGGERED[klass] = true
408
+ end
409
+
410
+ # If the method doesn't exist, the class may have been autoloaded by
411
+ # Zeitwerk without executing test-specific code (includes, helpers).
412
+ # Force load the file so all class-definition-time code executes.
413
+ unless klass.method_defined?(@method_name) || klass.private_method_defined?(@method_name)
414
+ if @file_path && @loader
415
+ @loader.load_file(@file_path)
416
+ RUNNABLE_METHODS_TRIGGERED.delete(klass)
417
+ klass.runnable_methods
418
+ RUNNABLE_METHODS_TRIGGERED[klass] = true
419
+ end
420
+ end
421
+
422
+ klass
423
+ end
424
+ end
425
+
426
+ def with_timestamps
427
+ start_timestamp = current_timestamp
428
+ result = yield
429
+ result
430
+ ensure
431
+ if result
432
+ result.start_timestamp = start_timestamp
433
+ result.finish_timestamp = current_timestamp
434
+ end
435
+ end
436
+
437
+ def run
438
+ with_timestamps do
439
+ if @load_error
440
+ build_error_result(@load_error)
441
+ elsif skip_stale_tests? && !(runnable.method_defined?(@method_name) || runnable.private_method_defined?(@method_name))
442
+ build_stale_skip_result
443
+ else
444
+ Minitest.run_one_method(runnable, @method_name)
445
+ end
446
+ rescue StandardError, ScriptError => error
447
+ build_error_result(error)
448
+ end
449
+ end
450
+
451
+ def flaky?
452
+ Minitest.queue.flaky?(self)
453
+ end
454
+
455
+ def source_location
456
+ return nil if @load_error
457
+
458
+ runnable.instance_method(@method_name).source_location
459
+ rescue NameError, NoMethodError, CI::Queue::FileLoadError, CI::Queue::ClassNotFoundError
460
+ nil
461
+ end
462
+
463
+ def marshal_dump
464
+ {
465
+ 'class_name' => @class_name,
466
+ 'method_name' => @method_name,
467
+ 'file_path' => @file_path,
468
+ 'load_error' => serialize_error(@load_error),
469
+ 'queue_entry' => @queue_entry_override,
470
+ }
471
+ end
472
+
473
+ def marshal_load(payload)
474
+ @class_name = payload['class_name']
475
+ @method_name = payload['method_name']
476
+ @file_path = payload['file_path']
477
+ @load_error = deserialize_error(payload['load_error'])
478
+ @queue_entry_override = payload['queue_entry']
479
+ @loader = CI::Queue::FileLoader.new
480
+ @resolver = CI::Queue::ClassResolver
481
+ @runnable = nil
482
+ @id = nil
483
+ @queue_entry = nil
484
+ end
485
+
486
+ private
487
+
488
+ def serialize_error(error)
489
+ return nil unless error
490
+
491
+ {
492
+ 'class' => error.class.name,
493
+ 'message' => error.message,
494
+ 'backtrace' => error.backtrace,
495
+ }
496
+ end
497
+
498
+ def deserialize_error(payload)
499
+ return nil unless payload
500
+
501
+ message = "#{payload['class']}: #{payload['message']}"
502
+ error = StandardError.new(message)
503
+ error.set_backtrace(payload['backtrace']) if payload['backtrace']
504
+ CI::Queue::FileLoadError.new(@file_path, error)
505
+ end
506
+
507
+ def build_error_result(error)
508
+ result_class = defined?(Minitest::Result) ? Minitest::Result : Minitest::Test
509
+ result = result_class.new(@method_name)
510
+ result.klass = @class_name if result.respond_to?(:klass=)
511
+ result.source_location = [@file_path || 'unknown', -1] if result.respond_to?(:source_location=)
512
+ result.failures << Minitest::UnexpectedError.new(error)
513
+ result
514
+ end
515
+
516
+ def skip_stale_tests?
517
+ Minitest.queue&.respond_to?(:config) && Minitest.queue.config.skip_stale_tests
518
+ end
519
+
520
+ def build_stale_skip_result
521
+ $stderr.puts "[ci-queue] Skipping stale preresolved entry: #{@class_name}##{@method_name} " \
522
+ "(method no longer exists in #{@file_path || 'unknown file'})"
523
+
524
+ result_class = defined?(Minitest::Result) ? Minitest::Result : Minitest::Test
525
+ result = result_class.new(@method_name)
526
+ result.klass = @class_name if result.respond_to?(:klass=)
527
+ result.source_location = [@file_path || 'unknown', -1] if result.respond_to?(:source_location=)
528
+ result.failures << Minitest::Skip.new(
529
+ "[ci-queue] Stale preresolved entry: #{@class_name}##{@method_name} no longer exists"
530
+ )
531
+ result
532
+ end
533
+
534
+ def current_timestamp
535
+ CI::Queue.time_now.to_i
536
+ end
537
+ end
538
+
273
539
  attr_accessor :queue
274
540
 
275
541
  def queue_reporters=(reporters)
@@ -310,10 +576,12 @@ if defined? Minitest::Result
310
576
  Minitest::Result.prepend(Minitest::Requeueing)
311
577
  Minitest::Result.prepend(Minitest::Flakiness)
312
578
  Minitest::Result.prepend(Minitest::WithTimestamps)
579
+ Minitest::Result.prepend(Minitest::ResultMetadata)
313
580
  else
314
581
  Minitest::Test.prepend(Minitest::Requeueing)
315
582
  Minitest::Test.prepend(Minitest::Flakiness)
316
583
  Minitest::Test.prepend(Minitest::WithTimestamps)
584
+ Minitest::Test.prepend(Minitest::ResultMetadata)
317
585
 
318
586
  module MinitestBackwardCompatibility
319
587
  def source_location
@@ -18,12 +18,14 @@ module RSpec
18
18
 
19
19
  def example_passed(notification)
20
20
  example = notification.example
21
- build.record_success(example.id)
21
+ entry = CI::Queue::QueueEntry.format(example.id, nil)
22
+ build.record_success(entry)
22
23
  end
23
24
 
24
25
  def example_failed(notification)
25
26
  example = notification.example
26
- build.record_error(example.id, dump(notification))
27
+ entry = CI::Queue::QueueEntry.format(example.id, nil)
28
+ build.record_error(entry, dump(notification))
27
29
  end
28
30
 
29
31
  private
data/lib/rspec/queue.rb CHANGED
@@ -253,6 +253,10 @@ module RSpec
253
253
  example.id
254
254
  end
255
255
 
256
+ def queue_entry
257
+ @queue_entry ||= CI::Queue::QueueEntry.format(id, nil)
258
+ end
259
+
256
260
  def <=>(other)
257
261
  id <=> other.id
258
262
  end
@@ -411,7 +415,7 @@ module RSpec
411
415
  end
412
416
 
413
417
  def requeue
414
- @queue.requeue(@example)
418
+ @queue.requeue(@example.queue_entry)
415
419
  end
416
420
 
417
421
  def cancel_run!
@@ -422,7 +426,7 @@ module RSpec
422
426
  end
423
427
 
424
428
  def acknowledge
425
- @queue.acknowledge(@example)
429
+ @queue.acknowledge(@example.queue_entry)
426
430
  end
427
431
  end
428
432
 
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.82.0
4
+ version: 0.84.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
@@ -149,6 +149,34 @@ dependencies:
149
149
  - - ">="
150
150
  - !ruby/object:Gem::Version
151
151
  version: '0'
152
+ - !ruby/object:Gem::Dependency
153
+ name: benchmark
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ - !ruby/object:Gem::Dependency
167
+ name: rexml
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
152
180
  - !ruby/object:Gem::Dependency
153
181
  name: rubocop
154
182
  requirement: !ruby/object:Gem::Requirement
@@ -190,11 +218,14 @@ files:
190
218
  - lib/ci/queue/bisect.rb
191
219
  - lib/ci/queue/build_record.rb
192
220
  - lib/ci/queue/circuit_breaker.rb
221
+ - lib/ci/queue/class_resolver.rb
193
222
  - lib/ci/queue/common.rb
194
223
  - lib/ci/queue/configuration.rb
195
224
  - lib/ci/queue/file.rb
225
+ - lib/ci/queue/file_loader.rb
196
226
  - lib/ci/queue/grind.rb
197
227
  - lib/ci/queue/output_helpers.rb
228
+ - lib/ci/queue/queue_entry.rb
198
229
  - lib/ci/queue/redis.rb
199
230
  - lib/ci/queue/redis/acknowledge.lua
200
231
  - lib/ci/queue/redis/base.rb
@@ -222,14 +253,18 @@ files:
222
253
  - lib/minitest/queue/grind_recorder.rb
223
254
  - lib/minitest/queue/grind_reporter.rb
224
255
  - lib/minitest/queue/junit_reporter.rb
256
+ - lib/minitest/queue/lazy_entry_resolver.rb
257
+ - lib/minitest/queue/lazy_test_discovery.rb
225
258
  - lib/minitest/queue/local_requeue_reporter.rb
226
259
  - lib/minitest/queue/order_reporter.rb
260
+ - lib/minitest/queue/queue_population_strategy.rb
227
261
  - lib/minitest/queue/runner.rb
228
262
  - lib/minitest/queue/statsd.rb
229
263
  - lib/minitest/queue/test_data.rb
230
264
  - lib/minitest/queue/test_data_reporter.rb
231
265
  - lib/minitest/queue/test_time_recorder.rb
232
266
  - lib/minitest/queue/test_time_reporter.rb
267
+ - lib/minitest/queue/worker_profile_reporter.rb
233
268
  - lib/minitest/reporters/bisect_reporter.rb
234
269
  - lib/minitest/reporters/statsd_reporter.rb
235
270
  - lib/rspec/queue.rb
@@ -249,14 +284,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
249
284
  requirements:
250
285
  - - ">="
251
286
  - !ruby/object:Gem::Version
252
- version: '2.7'
287
+ version: '3.1'
253
288
  required_rubygems_version: !ruby/object:Gem::Requirement
254
289
  requirements:
255
290
  - - ">="
256
291
  - !ruby/object:Gem::Version
257
292
  version: '0'
258
293
  requirements: []
259
- rubygems_version: 4.0.6
294
+ rubygems_version: 4.0.8
260
295
  specification_version: 4
261
296
  summary: Distribute tests over many workers using a queue
262
297
  test_files: []