ci-queue 0.82.0 → 0.83.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.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Queue
5
+ class WorkerProfileReporter
6
+ def initialize(supervisor, out: $stdout)
7
+ @supervisor = supervisor
8
+ @out = out
9
+ end
10
+
11
+ def print_summary
12
+ return unless CI::Queue.debug?
13
+
14
+ expected = @supervisor.workers_count
15
+ profiles = {}
16
+ 3.times do
17
+ profiles = @supervisor.build.worker_profiles
18
+ break if profiles.size >= expected
19
+ sleep 1
20
+ end
21
+ return if profiles.empty?
22
+
23
+ sorted = profiles.values.sort_by { |p| p['worker_id'].to_s }
24
+ mode = sorted.first&.dig('mode') || 'unknown'
25
+
26
+ @out.puts
27
+ @out.puts "Worker profile summary (#{sorted.size} workers, mode: #{mode}):"
28
+ @out.puts " %-12s %-12s %8s %14s %14s %14s %14s %10s" % ['Worker', 'Role', 'Tests', '1st Test', 'Wall Clock', 'Load Tests', 'File Load', 'Memory']
29
+ @out.puts " #{'-' * 100}"
30
+
31
+ sorted.each do |profile|
32
+ @out.puts format_profile_row(profile)
33
+ end
34
+
35
+ print_first_test_summary(sorted)
36
+ rescue StandardError
37
+ # Don't fail the build if profile printing fails
38
+ end
39
+
40
+ private
41
+
42
+ def format_profile_row(profile)
43
+ tests = profile['tests_run'] ? profile['tests_run'].to_s : 'n/a'
44
+ first_test = profile['time_to_first_test'] ? "#{profile['time_to_first_test']}s" : 'n/a'
45
+ wall = "#{profile['total_wall_clock']}s"
46
+ load_tests = profile['load_tests_duration'] ? "#{profile['load_tests_duration']}s" : 'n/a'
47
+ files = if profile['files_loaded'] && profile['total_files']
48
+ "#{profile['file_load_time']}s (#{profile['files_loaded']}/#{profile['total_files']})"
49
+ elsif profile['file_load_time']
50
+ "#{profile['file_load_time']}s"
51
+ else
52
+ 'n/a'
53
+ end
54
+ mem = profile['memory_rss_kb'] ? "#{(profile['memory_rss_kb'] / 1024.0).round(0)} MB" : 'n/a'
55
+
56
+ " %-12s %-12s %8s %14s %14s %14s %14s %10s" % [
57
+ profile['worker_id'], profile['role'], tests, first_test, wall, load_tests, files, mem
58
+ ]
59
+ end
60
+
61
+ def print_first_test_summary(sorted)
62
+ leaders = sorted.select { |p| p['role'] == 'leader' }
63
+ non_leaders = sorted.select { |p| p['role'] == 'non-leader' }
64
+ return unless leaders.any? && non_leaders.any?
65
+
66
+ leader_first = leaders.filter_map { |p| p['time_to_first_test'] }.min
67
+ nl_firsts = non_leaders.filter_map { |p| p['time_to_first_test'] }
68
+ return unless leader_first && nl_firsts.any?
69
+
70
+ avg_nl = (nl_firsts.sum / nl_firsts.size).round(2)
71
+ @out.puts
72
+ @out.puts " Leader time to 1st test: #{leader_first}s"
73
+ @out.puts " Avg non-leader time to 1st test: #{avg_nl}s"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -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?
@@ -194,6 +209,85 @@ module Minitest
194
209
 
195
210
  private
196
211
 
212
+ def report_load_stats(queue)
213
+ return unless CI::Queue.debug?
214
+ return unless queue.respond_to?(:file_loader)
215
+ return unless queue.respond_to?(:config) && queue.config.lazy_load
216
+
217
+ loader = queue.file_loader
218
+ return if loader.load_stats.empty?
219
+
220
+ total_time = loader.total_load_time
221
+ file_count = loader.load_stats.size
222
+ average = file_count.zero? ? 0 : (total_time / file_count)
223
+
224
+ puts
225
+ puts "File loading stats:"
226
+ puts " Total time: #{total_time.round(2)}s"
227
+ puts " Files loaded: #{file_count}"
228
+ puts " Average: #{average.round(3)}s per file"
229
+
230
+ slowest = loader.slowest_files(5)
231
+ return if slowest.empty?
232
+
233
+ puts " Slowest files:"
234
+ slowest.each do |file_path, duration|
235
+ puts " #{duration.round(3)}s - #{Minitest::Queue.relative_path(file_path)}"
236
+ end
237
+ end
238
+
239
+ def store_worker_profile(queue)
240
+ debug = CI::Queue.debug?
241
+ return unless queue.respond_to?(:config)
242
+ config = queue.config
243
+
244
+ run_start = Minitest::Queue::Runner.run_start
245
+ return unless run_start
246
+
247
+ run_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
248
+ profile = {
249
+ 'worker_id' => config.worker_id,
250
+ 'mode' => config.lazy_load ? 'lazy' : 'eager',
251
+ 'role' => queue.master? ? 'leader' : 'non-leader',
252
+ 'total_wall_clock' => (run_end - run_start).round(2),
253
+ }
254
+
255
+ first_test = queue.respond_to?(:first_reserve_at) ? queue.first_reserve_at : nil
256
+ profile['time_to_first_test'] = (first_test - run_start).round(2) if first_test
257
+
258
+ tests_run = queue.rescue_connection_errors { queue.worker_queue_length } if queue.respond_to?(:worker_queue_length)
259
+ profile['tests_run'] = tests_run.to_i if tests_run
260
+
261
+ load_tests_duration = Minitest::Queue::Runner.load_tests_duration
262
+ profile['load_tests_duration'] = load_tests_duration.round(2) if load_tests_duration
263
+
264
+ if queue.respond_to?(:file_loader) && queue.file_loader.load_stats.any?
265
+ loader = queue.file_loader
266
+ profile['files_loaded'] = loader.load_stats.size
267
+ profile['file_load_time'] = loader.total_load_time.round(2)
268
+ end
269
+
270
+ profile['total_files'] = Minitest::Queue::Runner.total_files if Minitest::Queue::Runner.total_files
271
+
272
+ rss_kb = begin
273
+ if File.exist?("/proc/#{Process.pid}/statm")
274
+ pages = Integer(File.read("/proc/#{Process.pid}/statm").split[1])
275
+ pages * 4
276
+ else
277
+ Integer(`ps -o rss= -p #{Process.pid}`.strip)
278
+ end
279
+ rescue
280
+ nil
281
+ end
282
+ profile['memory_rss_kb'] = rss_kb if rss_kb
283
+
284
+ queue.rescue_connection_errors do
285
+ queue.build.record_worker_profile(profile)
286
+ end
287
+ rescue => e
288
+ puts "WARNING: Failed to store worker profile: #{e.message}" if debug
289
+ end
290
+
197
291
  def rescue_run_errors(&block)
198
292
  block.call
199
293
  rescue Errno::EPIPE
@@ -232,6 +326,10 @@ module Minitest
232
326
  @id ||= "#{@runnable}##{@method_name}".freeze
233
327
  end
234
328
 
329
+ def queue_entry
330
+ id
331
+ end
332
+
235
333
  def <=>(other)
236
334
  id <=> other.id
237
335
  end
@@ -270,6 +368,171 @@ module Minitest
270
368
  end
271
369
  end
272
370
 
371
+ class LazySingleExample
372
+ attr_reader :class_name, :method_name, :file_path
373
+
374
+ def initialize(class_name, method_name, file_path, loader:, resolver:, load_error: nil, queue_entry: nil)
375
+ @class_name = class_name
376
+ @method_name = method_name
377
+ @file_path = file_path
378
+ @loader = loader
379
+ @resolver = resolver
380
+ @load_error = load_error
381
+ @queue_entry_override = queue_entry
382
+ @runnable = nil
383
+ end
384
+
385
+ def id
386
+ @id ||= "#{@class_name}##{@method_name}".freeze
387
+ end
388
+
389
+ def queue_entry
390
+ @queue_entry ||= @queue_entry_override || CI::Queue::QueueEntry.format(id, file_path)
391
+ end
392
+
393
+ def <=>(other)
394
+ id <=> other.id
395
+ end
396
+
397
+ RUNNABLE_METHODS_TRIGGERED = Concurrent::Map.new # :nodoc:
398
+
399
+ def runnable
400
+ @runnable ||= begin
401
+ klass = @resolver.resolve(@class_name, file_path: @file_path, loader: @loader)
402
+ unless RUNNABLE_METHODS_TRIGGERED[klass]
403
+ klass.runnable_methods
404
+ RUNNABLE_METHODS_TRIGGERED[klass] = true
405
+ end
406
+
407
+ # If the method doesn't exist, the class may have been autoloaded by
408
+ # Zeitwerk without executing test-specific code (includes, helpers).
409
+ # Force load the file so all class-definition-time code executes.
410
+ unless klass.method_defined?(@method_name) || klass.private_method_defined?(@method_name)
411
+ if @file_path && @loader
412
+ @loader.load_file(@file_path)
413
+ RUNNABLE_METHODS_TRIGGERED.delete(klass)
414
+ klass.runnable_methods
415
+ RUNNABLE_METHODS_TRIGGERED[klass] = true
416
+ end
417
+ end
418
+
419
+ klass
420
+ end
421
+ end
422
+
423
+ def with_timestamps
424
+ start_timestamp = current_timestamp
425
+ result = yield
426
+ result
427
+ ensure
428
+ if result
429
+ result.start_timestamp = start_timestamp
430
+ result.finish_timestamp = current_timestamp
431
+ end
432
+ end
433
+
434
+ def run
435
+ with_timestamps do
436
+ if @load_error
437
+ build_error_result(@load_error)
438
+ elsif skip_stale_tests? && !(runnable.method_defined?(@method_name) || runnable.private_method_defined?(@method_name))
439
+ build_stale_skip_result
440
+ else
441
+ Minitest.run_one_method(runnable, @method_name)
442
+ end
443
+ rescue StandardError, ScriptError => error
444
+ build_error_result(error)
445
+ end
446
+ end
447
+
448
+ def flaky?
449
+ Minitest.queue.flaky?(self)
450
+ end
451
+
452
+ def source_location
453
+ return nil if @load_error
454
+
455
+ runnable.instance_method(@method_name).source_location
456
+ rescue NameError, NoMethodError, CI::Queue::FileLoadError, CI::Queue::ClassNotFoundError
457
+ nil
458
+ end
459
+
460
+ def marshal_dump
461
+ {
462
+ 'class_name' => @class_name,
463
+ 'method_name' => @method_name,
464
+ 'file_path' => @file_path,
465
+ 'load_error' => serialize_error(@load_error),
466
+ 'queue_entry' => @queue_entry_override,
467
+ }
468
+ end
469
+
470
+ def marshal_load(payload)
471
+ @class_name = payload['class_name']
472
+ @method_name = payload['method_name']
473
+ @file_path = payload['file_path']
474
+ @load_error = deserialize_error(payload['load_error'])
475
+ @queue_entry_override = payload['queue_entry']
476
+ @loader = CI::Queue::FileLoader.new
477
+ @resolver = CI::Queue::ClassResolver
478
+ @runnable = nil
479
+ @id = nil
480
+ @queue_entry = nil
481
+ end
482
+
483
+ private
484
+
485
+ def serialize_error(error)
486
+ return nil unless error
487
+
488
+ {
489
+ 'class' => error.class.name,
490
+ 'message' => error.message,
491
+ 'backtrace' => error.backtrace,
492
+ }
493
+ end
494
+
495
+ def deserialize_error(payload)
496
+ return nil unless payload
497
+
498
+ message = "#{payload['class']}: #{payload['message']}"
499
+ error = StandardError.new(message)
500
+ error.set_backtrace(payload['backtrace']) if payload['backtrace']
501
+ CI::Queue::FileLoadError.new(@file_path, error)
502
+ end
503
+
504
+ def build_error_result(error)
505
+ result_class = defined?(Minitest::Result) ? Minitest::Result : Minitest::Test
506
+ result = result_class.new(@method_name)
507
+ result.klass = @class_name if result.respond_to?(:klass=)
508
+ result.source_location = [@file_path || 'unknown', -1] if result.respond_to?(:source_location=)
509
+ result.failures << Minitest::UnexpectedError.new(error)
510
+ result
511
+ end
512
+
513
+ def skip_stale_tests?
514
+ Minitest.queue&.respond_to?(:config) && Minitest.queue.config.skip_stale_tests
515
+ end
516
+
517
+ def build_stale_skip_result
518
+ $stderr.puts "[ci-queue] Skipping stale preresolved entry: #{@class_name}##{@method_name} " \
519
+ "(method no longer exists in #{@file_path || 'unknown file'})"
520
+
521
+ result_class = defined?(Minitest::Result) ? Minitest::Result : Minitest::Test
522
+ result = result_class.new(@method_name)
523
+ result.klass = @class_name if result.respond_to?(:klass=)
524
+ result.source_location = [@file_path || 'unknown', -1] if result.respond_to?(:source_location=)
525
+ result.failures << Minitest::Skip.new(
526
+ "[ci-queue] Stale preresolved entry: #{@class_name}##{@method_name} no longer exists"
527
+ )
528
+ result
529
+ end
530
+
531
+ def current_timestamp
532
+ CI::Queue.time_now.to_i
533
+ end
534
+ end
535
+
273
536
  attr_accessor :queue
274
537
 
275
538
  def queue_reporters=(reporters)
@@ -310,10 +573,12 @@ if defined? Minitest::Result
310
573
  Minitest::Result.prepend(Minitest::Requeueing)
311
574
  Minitest::Result.prepend(Minitest::Flakiness)
312
575
  Minitest::Result.prepend(Minitest::WithTimestamps)
576
+ Minitest::Result.prepend(Minitest::ResultMetadata)
313
577
  else
314
578
  Minitest::Test.prepend(Minitest::Requeueing)
315
579
  Minitest::Test.prepend(Minitest::Flakiness)
316
580
  Minitest::Test.prepend(Minitest::WithTimestamps)
581
+ Minitest::Test.prepend(Minitest::ResultMetadata)
317
582
 
318
583
  module MinitestBackwardCompatibility
319
584
  def source_location
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.83.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
@@ -190,12 +190,16 @@ files:
190
190
  - lib/ci/queue/bisect.rb
191
191
  - lib/ci/queue/build_record.rb
192
192
  - lib/ci/queue/circuit_breaker.rb
193
+ - lib/ci/queue/class_resolver.rb
193
194
  - lib/ci/queue/common.rb
194
195
  - lib/ci/queue/configuration.rb
195
196
  - lib/ci/queue/file.rb
197
+ - lib/ci/queue/file_loader.rb
196
198
  - lib/ci/queue/grind.rb
197
199
  - lib/ci/queue/output_helpers.rb
200
+ - lib/ci/queue/queue_entry.rb
198
201
  - lib/ci/queue/redis.rb
202
+ - lib/ci/queue/redis/_entry_helpers.lua
199
203
  - lib/ci/queue/redis/acknowledge.lua
200
204
  - lib/ci/queue/redis/base.rb
201
205
  - lib/ci/queue/redis/build_record.rb
@@ -222,14 +226,18 @@ files:
222
226
  - lib/minitest/queue/grind_recorder.rb
223
227
  - lib/minitest/queue/grind_reporter.rb
224
228
  - lib/minitest/queue/junit_reporter.rb
229
+ - lib/minitest/queue/lazy_entry_resolver.rb
230
+ - lib/minitest/queue/lazy_test_discovery.rb
225
231
  - lib/minitest/queue/local_requeue_reporter.rb
226
232
  - lib/minitest/queue/order_reporter.rb
233
+ - lib/minitest/queue/queue_population_strategy.rb
227
234
  - lib/minitest/queue/runner.rb
228
235
  - lib/minitest/queue/statsd.rb
229
236
  - lib/minitest/queue/test_data.rb
230
237
  - lib/minitest/queue/test_data_reporter.rb
231
238
  - lib/minitest/queue/test_time_recorder.rb
232
239
  - lib/minitest/queue/test_time_reporter.rb
240
+ - lib/minitest/queue/worker_profile_reporter.rb
233
241
  - lib/minitest/reporters/bisect_reporter.rb
234
242
  - lib/minitest/reporters/statsd_reporter.rb
235
243
  - lib/rspec/queue.rb