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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +109 -0
- data/lib/ci/queue/build_record.rb +22 -10
- data/lib/ci/queue/class_resolver.rb +38 -0
- data/lib/ci/queue/configuration.rb +62 -1
- data/lib/ci/queue/file_loader.rb +101 -0
- data/lib/ci/queue/queue_entry.rb +56 -0
- data/lib/ci/queue/redis/_entry_helpers.lua +10 -0
- data/lib/ci/queue/redis/acknowledge.lua +10 -7
- data/lib/ci/queue/redis/base.rb +34 -8
- data/lib/ci/queue/redis/build_record.rb +89 -22
- data/lib/ci/queue/redis/grind_record.rb +17 -13
- data/lib/ci/queue/redis/heartbeat.lua +9 -4
- data/lib/ci/queue/redis/monitor.rb +19 -5
- data/lib/ci/queue/redis/requeue.lua +19 -11
- data/lib/ci/queue/redis/reserve.lua +47 -8
- data/lib/ci/queue/redis/reserve_lost.lua +5 -1
- data/lib/ci/queue/redis/supervisor.rb +3 -3
- data/lib/ci/queue/redis/worker.rb +216 -23
- data/lib/ci/queue/redis.rb +0 -1
- data/lib/ci/queue/version.rb +1 -1
- data/lib/ci/queue.rb +27 -0
- data/lib/minitest/queue/build_status_recorder.rb +32 -14
- data/lib/minitest/queue/grind_recorder.rb +3 -3
- data/lib/minitest/queue/junit_reporter.rb +2 -2
- data/lib/minitest/queue/lazy_entry_resolver.rb +55 -0
- data/lib/minitest/queue/lazy_test_discovery.rb +169 -0
- data/lib/minitest/queue/local_requeue_reporter.rb +11 -0
- data/lib/minitest/queue/order_reporter.rb +9 -2
- data/lib/minitest/queue/queue_population_strategy.rb +176 -0
- data/lib/minitest/queue/runner.rb +117 -27
- data/lib/minitest/queue/test_data.rb +14 -1
- data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
- data/lib/minitest/queue.rb +271 -6
- metadata +10 -3
- 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
|
-
|
|
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
|
-
|
|
101
|
-
populate_queue
|
|
109
|
+
prepare_queue_for_execution
|
|
102
110
|
end
|
|
103
111
|
else
|
|
104
|
-
|
|
105
|
-
populate_queue
|
|
112
|
+
prepare_queue_for_execution
|
|
106
113
|
end
|
|
107
114
|
end
|
|
108
115
|
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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.
|
|
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
|