ci-queue 0.81.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +109 -0
  4. data/lib/ci/queue/build_record.rb +22 -10
  5. data/lib/ci/queue/class_resolver.rb +38 -0
  6. data/lib/ci/queue/configuration.rb +62 -1
  7. data/lib/ci/queue/file_loader.rb +101 -0
  8. data/lib/ci/queue/queue_entry.rb +56 -0
  9. data/lib/ci/queue/redis/_entry_helpers.lua +10 -0
  10. data/lib/ci/queue/redis/acknowledge.lua +10 -7
  11. data/lib/ci/queue/redis/base.rb +34 -8
  12. data/lib/ci/queue/redis/build_record.rb +89 -22
  13. data/lib/ci/queue/redis/grind_record.rb +17 -13
  14. data/lib/ci/queue/redis/heartbeat.lua +9 -4
  15. data/lib/ci/queue/redis/monitor.rb +19 -5
  16. data/lib/ci/queue/redis/requeue.lua +19 -11
  17. data/lib/ci/queue/redis/reserve.lua +47 -8
  18. data/lib/ci/queue/redis/reserve_lost.lua +5 -1
  19. data/lib/ci/queue/redis/supervisor.rb +3 -3
  20. data/lib/ci/queue/redis/worker.rb +216 -23
  21. data/lib/ci/queue/redis.rb +0 -1
  22. data/lib/ci/queue/version.rb +1 -1
  23. data/lib/ci/queue.rb +27 -0
  24. data/lib/minitest/queue/build_status_recorder.rb +32 -14
  25. data/lib/minitest/queue/grind_recorder.rb +3 -3
  26. data/lib/minitest/queue/junit_reporter.rb +2 -2
  27. data/lib/minitest/queue/lazy_entry_resolver.rb +55 -0
  28. data/lib/minitest/queue/lazy_test_discovery.rb +169 -0
  29. data/lib/minitest/queue/local_requeue_reporter.rb +11 -0
  30. data/lib/minitest/queue/order_reporter.rb +9 -2
  31. data/lib/minitest/queue/queue_population_strategy.rb +176 -0
  32. data/lib/minitest/queue/runner.rb +117 -27
  33. data/lib/minitest/queue/test_data.rb +14 -1
  34. data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
  35. data/lib/minitest/queue.rb +271 -6
  36. metadata +10 -3
  37. data/lib/ci/queue/redis/key_shortener.rb +0 -53
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'zlib'
5
+
6
+ module Minitest
7
+ module Queue
8
+ class LazyTestDiscovery
9
+ def initialize(loader:, resolver:)
10
+ @loader = loader
11
+ @resolver = resolver
12
+ end
13
+
14
+ def enumerator(files)
15
+ Enumerator.new do |yielder|
16
+ each_test(files) do |test|
17
+ yielder << test
18
+ end
19
+ end
20
+ end
21
+
22
+ def each_test(files)
23
+ discovery_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ total_files = 0
25
+ new_runnable_files = 0
26
+ reopened_files = 0
27
+ reopened_candidates = 0
28
+ reopened_scan_time = 0.0
29
+
30
+ seen = Set.new
31
+ runnables = Minitest::Test.runnables
32
+ known_count = runnables.size
33
+ by_full_name = {}
34
+ by_short_name = Hash.new { |h, k| h[k] = [] }
35
+ index_runnables(runnables, by_full_name, by_short_name)
36
+
37
+ files.each do |file|
38
+ total_files += 1
39
+ file_path = File.expand_path(file)
40
+ begin
41
+ @loader.load_file(file_path)
42
+ rescue CI::Queue::FileLoadError => error
43
+ method_name = "load_file_#{Zlib.crc32(file_path).to_s(16)}"
44
+ class_name = "CIQueue::FileLoadError"
45
+ test_id = "#{class_name}##{method_name}"
46
+ entry = CI::Queue::QueueEntry.format(
47
+ test_id,
48
+ CI::Queue::QueueEntry.encode_load_error(file_path, error),
49
+ )
50
+ yield Minitest::Queue::LazySingleExample.new(
51
+ class_name,
52
+ method_name,
53
+ file_path,
54
+ loader: @loader,
55
+ resolver: @resolver,
56
+ load_error: error,
57
+ queue_entry: entry,
58
+ )
59
+ next
60
+ end
61
+
62
+ runnables = Minitest::Test.runnables
63
+ candidates = []
64
+ if runnables.size > known_count
65
+ new_runnables = runnables[known_count..]
66
+ known_count = runnables.size
67
+ index_runnables(new_runnables, by_full_name, by_short_name)
68
+ candidates.concat(new_runnables)
69
+ new_runnable_files += 1
70
+ else
71
+ # Re-opened classes do not increase runnables size. In that case, map
72
+ # declared class names in the file to known runnables directly.
73
+ reopened_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ reopened = reopened_runnables_for_file(file_path, by_full_name, by_short_name)
75
+ reopened_scan_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - reopened_start
76
+ unless reopened.empty?
77
+ reopened_files += 1
78
+ reopened_candidates += reopened.size
79
+ end
80
+ candidates.concat(reopened)
81
+ end
82
+
83
+ enqueue_discovered_tests(candidates.uniq, file_path, seen) do |test|
84
+ yield test
85
+ end
86
+ end
87
+ ensure
88
+ debug_discovery_profile(
89
+ discovery_start: discovery_start,
90
+ total_files: total_files,
91
+ new_runnable_files: new_runnable_files,
92
+ reopened_files: reopened_files,
93
+ reopened_candidates: reopened_candidates,
94
+ reopened_scan_time: reopened_scan_time,
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ def reopened_runnables_for_file(file_path, by_full_name, by_short_name)
101
+ declared = declared_class_names(file_path)
102
+ return [] if declared.empty?
103
+
104
+ declared.each_with_object([]) do |name, runnables|
105
+ runnable = by_full_name[name]
106
+ if runnable
107
+ runnables << runnable
108
+ next
109
+ end
110
+
111
+ short_name = name.split('::').last
112
+ runnables.concat(by_short_name[short_name])
113
+ end
114
+ end
115
+
116
+ def index_runnables(runnables, by_full_name, by_short_name)
117
+ runnables.each do |runnable|
118
+ name = runnable.name
119
+ next unless name
120
+
121
+ by_full_name[name] ||= runnable
122
+ short_name = name.split('::').last
123
+ by_short_name[short_name] << runnable
124
+ end
125
+ end
126
+
127
+ def debug_discovery_profile(discovery_start:, total_files:, new_runnable_files:, reopened_files:, reopened_candidates:, reopened_scan_time:)
128
+ return unless CI::Queue.debug?
129
+
130
+ total_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - discovery_start
131
+ puts "[ci-queue][lazy-discovery] files=#{total_files} new_runnable_files=#{new_runnable_files} " \
132
+ "reopened_files=#{reopened_files} reopened_candidates=#{reopened_candidates} " \
133
+ "reopened_scan_time=#{reopened_scan_time.round(2)}s total_time=#{total_time.round(2)}s"
134
+ end
135
+
136
+ def enqueue_discovered_tests(runnables, file_path, seen)
137
+ runnables.each do |runnable|
138
+ runnable.runnable_methods.each do |method_name|
139
+ test_id = "#{runnable.name}##{method_name}"
140
+ next if seen.include?(test_id)
141
+
142
+ seen.add(test_id)
143
+ yield Minitest::Queue::LazySingleExample.new(
144
+ runnable.name,
145
+ method_name,
146
+ file_path,
147
+ loader: @loader,
148
+ resolver: @resolver,
149
+ )
150
+ rescue NameError, NoMethodError
151
+ next
152
+ end
153
+ end
154
+ end
155
+
156
+ def declared_class_names(file_path)
157
+ names = Set.new
158
+ ::File.foreach(file_path) do |line|
159
+ match = line.match(/^\s*class\s+([A-Z]\w*(?:::[A-Z]\w*)*)\b/)
160
+ names.add(match[1]) if match
161
+ end
162
+ names
163
+ rescue SystemCallError
164
+ Set.new
165
+ end
166
+
167
+ end
168
+ end
169
+ end
@@ -46,6 +46,17 @@ module Minitest
46
46
  def result_line
47
47
  "#{super}, #{requeues} requeues"
48
48
  end
49
+
50
+ def location(exception)
51
+ backtrace = exception.backtrace
52
+ return super if backtrace && !backtrace.empty?
53
+
54
+ nested_exception = exception.respond_to?(:error) ? exception.error : nil
55
+ nested_backtrace = nested_exception&.backtrace
56
+ return super(nested_exception) if nested_backtrace && !nested_backtrace.empty?
57
+
58
+ 'unknown'
59
+ end
49
60
  end
50
61
  end
51
62
  end
@@ -5,6 +5,9 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
5
5
  def initialize(options = {})
6
6
  @path = options.delete(:path)
7
7
  @file = nil
8
+ @flush_every = Integer(ENV.fetch('CI_QUEUE_ORDER_FLUSH_EVERY', '50'))
9
+ @flush_every = 1 if @flush_every < 1
10
+ @pending = 0
8
11
  super
9
12
  end
10
13
 
@@ -16,10 +19,15 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
16
19
  def before_test(test)
17
20
  super
18
21
  file.puts("#{test.class.name}##{test.name}")
19
- file.flush
22
+ @pending += 1
23
+ if @pending >= @flush_every
24
+ file.flush
25
+ @pending = 0
26
+ end
20
27
  end
21
28
 
22
29
  def report
30
+ file.flush
23
31
  file.close
24
32
  end
25
33
 
@@ -29,4 +37,3 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
29
37
  @file ||= File.open(@path, 'a+')
30
38
  end
31
39
  end
32
-
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'minitest/queue/lazy_entry_resolver'
5
+ require 'minitest/queue/lazy_test_discovery'
6
+
7
+ module Minitest
8
+ module Queue
9
+ class QueuePopulationStrategy
10
+ attr_reader :load_tests_duration, :total_files
11
+
12
+ def initialize(queue:, queue_config:, argv:, test_files_file:, ordering_seed:, preresolved_test_list: nil)
13
+ @queue = queue
14
+ @queue_config = queue_config
15
+ @argv = argv
16
+ @test_files_file = test_files_file
17
+ @ordering_seed = ordering_seed
18
+ @preresolved_test_list = preresolved_test_list
19
+ end
20
+
21
+ def load_and_populate!
22
+ load_tests
23
+ populate_queue
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :queue, :queue_config, :argv, :test_files_file, :ordering_seed, :preresolved_test_list
29
+
30
+ def populate_queue
31
+ if preresolved_test_list && queue.respond_to?(:stream_populate)
32
+ configure_lazy_queue
33
+ queue.stream_populate(preresolved_entry_enumerator, random: ordering_seed, batch_size: queue_config.lazy_load_stream_batch_size)
34
+ elsif queue_config.lazy_load && queue.respond_to?(:stream_populate)
35
+ configure_lazy_queue
36
+ queue.stream_populate(lazy_test_enumerator, random: ordering_seed, batch_size: queue_config.lazy_load_stream_batch_size)
37
+ else
38
+ queue.populate(Minitest.loaded_tests, random: ordering_seed)
39
+ end
40
+ end
41
+
42
+ def load_tests
43
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ if preresolved_test_list || queue_config.lazy_load
45
+ # In preresolved or lazy-load mode, test files are loaded on-demand by the entry resolver.
46
+ # Load test helpers (e.g., test/test_helper.rb via CI_QUEUE_LAZY_LOAD_TEST_HELPERS)
47
+ # to boot the app for all workers.
48
+ queue_config.lazy_load_test_helper_paths.each do |helper_path|
49
+ require File.expand_path(helper_path)
50
+ end
51
+ else
52
+ test_file_list.sort.each do |file_path|
53
+ require File.expand_path(file_path)
54
+ end
55
+ end
56
+ ensure
57
+ @load_tests_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
58
+ @total_files = begin
59
+ preresolved_test_list ? nil : test_file_list.size
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+
65
+ def configure_lazy_queue
66
+ return unless queue.respond_to?(:entry_resolver=)
67
+
68
+ queue.entry_resolver = lazy_entry_resolver
69
+ end
70
+
71
+ def lazy_entry_resolver
72
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
73
+ resolver = CI::Queue::ClassResolver
74
+ Minitest::Queue::LazyEntryResolver.new(loader: loader, resolver: resolver)
75
+ end
76
+
77
+ # Reads a pre-resolved test list file (one entry per line: "TestId|file/path.rb")
78
+ # and yields entries in the internal tab-delimited QueueEntry format.
79
+ #
80
+ # When --test-files is also provided, those files act as a reconcile set:
81
+ # preresolved entries whose file path matches are skipped (they may be stale),
82
+ # and the files are lazily discovered to enqueue their current tests.
83
+ # This runs only on the leader (stream_populate is leader-only), so discovery
84
+ # happens exactly once per build.
85
+ def preresolved_entry_enumerator
86
+ override_files = reconcile_file_set
87
+
88
+ Enumerator.new do |yielder|
89
+ preresolved_kept = 0
90
+ preresolved_skipped = 0
91
+
92
+ File.foreach(preresolved_test_list) do |line|
93
+ line = line.chomp
94
+ next if line.strip.empty?
95
+
96
+ # Split on the LAST pipe — test method names can contain '|'
97
+ # (e.g., regex patterns, boolean conditions) but file paths never do.
98
+ test_id, _, file_path = line.rpartition('|')
99
+ if test_id.empty?
100
+ # No pipe found — treat the whole line as a test ID with no file path
101
+ test_id = file_path
102
+ file_path = nil
103
+ end
104
+
105
+ # Skip entries for files that will be re-discovered via --test-files
106
+ if file_path && override_files.include?(file_path)
107
+ preresolved_skipped += 1
108
+ next
109
+ end
110
+
111
+ preresolved_kept += 1
112
+ yielder << CI::Queue::QueueEntry.format(test_id, file_path)
113
+ end
114
+
115
+ if CI::Queue.debug?
116
+ puts "[ci-queue][preresolved] kept=#{preresolved_kept} skipped=#{preresolved_skipped} " \
117
+ "reconcile_files=#{override_files.size}"
118
+ end
119
+
120
+ # Lazily discover current tests for each reconciled file
121
+ unless override_files.empty?
122
+ discovery_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ discovered = 0
124
+ lazy_test_discovery.each_test(override_files.to_a) do |example|
125
+ discovered += 1
126
+ yielder << example.queue_entry
127
+ end
128
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - discovery_start
129
+ if CI::Queue.debug?
130
+ puts "[ci-queue][reconcile-discovery] discovered=#{discovered} files=#{override_files.size} " \
131
+ "duration=#{duration.round(2)}s"
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ def lazy_test_discovery
138
+ @lazy_test_discovery ||= begin
139
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
140
+ Minitest::Queue::LazyTestDiscovery.new(loader: loader, resolver: CI::Queue::ClassResolver)
141
+ end
142
+ end
143
+
144
+ # In preresolved mode, --test-files provides the reconcile set: test files
145
+ # whose preresolved entries should be discarded and re-discovered.
146
+ # Returns an empty Set if no test files file is provided.
147
+ def reconcile_file_set
148
+ return Set.new unless test_files_file
149
+ return Set.new unless File.exist?(test_files_file)
150
+
151
+ Set.new(File.readlines(test_files_file, chomp: true).reject(&:empty?))
152
+ end
153
+
154
+ def lazy_test_enumerator
155
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
156
+ resolver = CI::Queue::ClassResolver
157
+ files = test_file_list.sort
158
+
159
+ Minitest::Queue::LazyTestDiscovery.new(loader: loader, resolver: resolver).enumerator(files)
160
+ end
161
+
162
+ # Returns the list of test files to process. Prefers --test-files FILE
163
+ # (reads paths from a file, one per line) over positional argv arguments.
164
+ # --test-files avoids ARG_MAX limits for large test suites (36K+ files).
165
+ def test_file_list
166
+ @test_file_list ||= begin
167
+ if test_files_file
168
+ File.readlines(test_files_file, chomp: true).reject { |f| f.strip.empty? }
169
+ else
170
+ argv
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  require 'optparse'
3
3
  require 'json'
4
+ require 'fileutils'
4
5
  require 'minitest/queue'
5
6
  require 'ci/queue'
6
7
  require 'digest/md5'
7
8
  require 'minitest/reporters/bisect_reporter'
8
9
  require 'minitest/reporters/statsd_reporter'
10
+ require 'minitest/queue/queue_population_strategy'
11
+ require 'minitest/queue/worker_profile_reporter'
9
12
 
10
13
  module Minitest
11
14
  module Queue
@@ -15,6 +18,10 @@ module Minitest
15
18
  Error = Class.new(StandardError)
16
19
  MissingParameter = Class.new(Error)
17
20
 
21
+ class << self
22
+ attr_accessor :run_start, :load_tests_duration, :total_files
23
+ end
24
+
18
25
  def self.invoke(argv)
19
26
  new(argv).run!
20
27
  end
@@ -48,6 +55,8 @@ module Minitest
48
55
  end
49
56
 
50
57
  def run_command
58
+ Runner.run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+
51
60
  require_worker_id!
52
61
  # if it's an automatic job retry we should process the main queue
53
62
  if manual_retry?
@@ -97,19 +106,27 @@ module Minitest
97
106
  if remaining <= running
98
107
  puts green("Queue almost empty, exiting early...")
99
108
  else
100
- load_tests
101
- populate_queue
109
+ prepare_queue_for_execution
102
110
  end
103
111
  else
104
- load_tests
105
- populate_queue
112
+ prepare_queue_for_execution
106
113
  end
107
114
  end
108
115
 
109
- at_exit {
116
+ if queue_config.lazy_load
117
+ # In lazy-load mode, run minitest explicitly instead of relying on
118
+ # minitest/autorun's at_exit hook, which may not be registered since
119
+ # test files haven't been loaded yet. exit! prevents double-execution
120
+ # if minitest/autorun was loaded by the leader during streaming.
121
+ passed = Minitest.run []
110
122
  verify_reporters!(reporters)
111
- }
112
- # Let minitest's at_exit hook trigger
123
+ exit!(passed ? 0 : 1)
124
+ else
125
+ at_exit {
126
+ verify_reporters!(reporters)
127
+ }
128
+ # Let minitest's at_exit hook trigger
129
+ end
113
130
  end
114
131
 
115
132
  def verify_reporters!(reporters)
@@ -170,11 +187,9 @@ module Minitest
170
187
  trap('TERM') { Minitest.queue.shutdown! }
171
188
  trap('INT') { Minitest.queue.shutdown! }
172
189
 
173
- load_tests
174
-
175
190
  @queue = CI::Queue::Grind.new(grind_list, queue_config)
176
191
  Minitest.queue = queue
177
- populate_queue
192
+ prepare_queue_for_execution
178
193
 
179
194
  # Let minitest's at_exit hook trigger
180
195
  end
@@ -183,10 +198,9 @@ module Minitest
183
198
  invalid_usage! "Missing the FAILING_TEST argument." unless queue_config.failing_test
184
199
 
185
200
  set_load_path
186
- load_tests
187
201
  @queue = CI::Queue::Bisect.new(queue_url, queue_config)
188
202
  Minitest.queue = queue
189
- populate_queue
203
+ prepare_queue_for_execution
190
204
 
191
205
  step("Testing the failing test in isolation")
192
206
  unless queue.failing_test_present?
@@ -242,16 +256,16 @@ module Minitest
242
256
  puts
243
257
 
244
258
  File.write('log/test_order.log', failing_order.to_a.map(&:id).join("\n"))
245
-
259
+
246
260
  bisect_test_details = failing_order.to_a.map do |test|
247
261
  source_location = test.source_location
248
262
  file_path = source_location&.first || 'unknown'
249
263
  line_number = source_location&.last || -1
250
264
  "#{test.id} #{file_path}:#{line_number}"
251
265
  end
252
-
266
+
253
267
  File.write('log/bisect_test_details.log', bisect_test_details.join("\n"))
254
-
268
+
255
269
  exit! 0
256
270
  end
257
271
  end
@@ -284,6 +298,7 @@ module Minitest
284
298
  reporter.write_failure_file(queue_config.failure_file) if queue_config.failure_file
285
299
  reporter.write_flaky_tests_file(queue_config.export_flaky_tests_file) if queue_config.export_flaky_tests_file
286
300
  exit_code = reporter.report
301
+ print_worker_profiles(supervisor)
287
302
  exit! exit_code
288
303
  end
289
304
 
@@ -321,7 +336,7 @@ module Minitest
321
336
 
322
337
  attr_reader :queue_config, :options, :command, :argv
323
338
  attr_writer :queue_url
324
- attr_accessor :queue, :grind_list, :grind_count, :load_paths, :verbose
339
+ attr_accessor :queue, :grind_list, :grind_count, :load_paths, :verbose, :test_files_file, :preresolved_test_list
325
340
 
326
341
  def require_worker_id!
327
342
  if queue.distributed?
@@ -336,8 +351,22 @@ module Minitest
336
351
  warnings = build.pop_warnings.map do |type, attributes|
337
352
  attributes.merge(type: type)
338
353
  end.compact
339
- File.open(queue_config.warnings_file, 'w') do |f|
340
- JSON.dump(warnings, f)
354
+
355
+ return if warnings.empty?
356
+
357
+ begin
358
+ # Ensure directory exists
359
+ dir = File.dirname(queue_config.warnings_file)
360
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
361
+
362
+ # Write each warning as a separate JSON line (JSONL format)
363
+ File.open(queue_config.warnings_file, 'a') do |f|
364
+ warnings.each do |warning|
365
+ f.puts(JSON.dump(warning))
366
+ end
367
+ end
368
+ rescue => error
369
+ STDERR.puts "Failed to write warnings: #{error.message}"
341
370
  end
342
371
  end
343
372
 
@@ -357,10 +386,6 @@ module Minitest
357
386
  queue.build.reset_worker_error
358
387
  end
359
388
 
360
- def populate_queue
361
- Minitest.queue.populate(Minitest.loaded_tests, random: ordering_seed)
362
- end
363
-
364
389
  def set_load_path
365
390
  if paths = load_paths
366
391
  paths.split(':').reverse.each do |path|
@@ -369,10 +394,22 @@ module Minitest
369
394
  end
370
395
  end
371
396
 
372
- def load_tests
373
- argv.sort.each do |f|
374
- require File.expand_path(f)
375
- end
397
+ def prepare_queue_for_execution
398
+ strategy = queue_population_strategy
399
+ strategy.load_and_populate!
400
+ Runner.load_tests_duration = strategy.load_tests_duration
401
+ Runner.total_files = strategy.total_files
402
+ end
403
+
404
+ def queue_population_strategy
405
+ Minitest::Queue::QueuePopulationStrategy.new(
406
+ queue: queue,
407
+ queue_config: queue_config,
408
+ argv: argv,
409
+ test_files_file: test_files_file,
410
+ ordering_seed: ordering_seed,
411
+ preresolved_test_list: preresolved_test_list,
412
+ )
376
413
  end
377
414
 
378
415
  def parse(argv)
@@ -381,6 +418,10 @@ module Minitest
381
418
  return command, argv
382
419
  end
383
420
 
421
+ def print_worker_profiles(supervisor)
422
+ Minitest::Queue::WorkerProfileReporter.new(supervisor).print_summary
423
+ end
424
+
384
425
  def parser
385
426
  @parser ||= OptionParser.new do |opts|
386
427
  opts.banner = "Usage: minitest-queue [options] COMMAND [ARGS]"
@@ -487,7 +528,56 @@ module Minitest
487
528
  end
488
529
 
489
530
  help = <<~EOS
490
- Sepcify a seed used to shuffle the test suite.
531
+ Load test files on demand instead of eagerly loading all tests.
532
+ EOS
533
+ opts.separator ""
534
+ opts.on('--lazy-load', help) do
535
+ queue_config.lazy_load = true
536
+ end
537
+
538
+ help = <<~EOS
539
+ Batch size for streaming tests to Redis in lazy mode.
540
+ Defaults to 5000.
541
+ EOS
542
+ opts.separator ""
543
+ opts.on('--lazy-load-stream-batch-size SIZE', Integer, help) do |size|
544
+ queue_config.lazy_load_stream_batch_size = size
545
+ end
546
+
547
+ help = <<~EOS
548
+ Read test file paths from FILE (one per line) instead of positional arguments.
549
+ Useful for large test suites to avoid ARG_MAX limits.
550
+ EOS
551
+ opts.separator ""
552
+ opts.on('--test-files FILE', help) do |file|
553
+ self.test_files_file = file
554
+ end
555
+
556
+ help = <<~EOS
557
+ Read a pre-resolved test list from FILE instead of discovering tests from files.
558
+ Each line must be in the format: TestClass#method_name|path/to/test_file.rb
559
+ The leader streams entries directly to Redis without loading or discovering tests.
560
+ Implies lazy-load mode for all workers.
561
+ When combined with --test-files, entries whose file path appears in that list
562
+ are skipped and the files are lazily re-discovered (reconciliation).
563
+ EOS
564
+ opts.separator ""
565
+ opts.on('--preresolved-tests FILE', help) do |file|
566
+ self.preresolved_test_list = file
567
+ queue_config.lazy_load = true
568
+ end
569
+
570
+ help = <<~EOS
571
+ Timeout in seconds without new streamed batches before failing.
572
+ Defaults to the max of --queue-init-timeout and 300 seconds.
573
+ EOS
574
+ opts.separator ""
575
+ opts.on('--lazy-load-stream-timeout SECONDS', Integer, help) do |seconds|
576
+ queue_config.lazy_load_streaming_timeout = seconds
577
+ end
578
+
579
+ help = <<~EOS
580
+ Specify a seed used to shuffle the test suite.
491
581
  On Buildkite, CircleCI, Heroku CI, and Travis, the commit revision will be used by default.
492
582
  EOS
493
583
  opts.separator ""
@@ -137,11 +137,13 @@ module Minitest
137
137
  def error_location(exception)
138
138
  @error_location ||= begin
139
139
  last_before_assertion = ''
140
- exception.backtrace.reverse_each do |s|
140
+ backtrace_for(exception).reverse_each do |s|
141
141
  break if s =~ /in .(assert|refute|flunk|pass|fail|raise|must|wont)/
142
142
 
143
143
  last_before_assertion = s
144
144
  end
145
+ return ['unknown', 0] if last_before_assertion.empty?
146
+
145
147
  path = last_before_assertion.sub(/:in .*$/, '')
146
148
  # the path includes the linenumber at the end,
147
149
  # which is seperated by a :
@@ -151,6 +153,17 @@ module Minitest
151
153
  [result.first, result.last.to_i]
152
154
  end
153
155
  end
156
+
157
+ def backtrace_for(exception)
158
+ backtrace = exception.backtrace
159
+ return backtrace if backtrace && !backtrace.empty?
160
+
161
+ nested_exception = exception.respond_to?(:error) ? exception.error : nil
162
+ nested_backtrace = nested_exception&.backtrace
163
+ return nested_backtrace if nested_backtrace && !nested_backtrace.empty?
164
+
165
+ []
166
+ end
154
167
  end
155
168
  end
156
169
  end