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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile.lock +59 -47
- data/README.md +87 -0
- data/ci-queue.gemspec +3 -1
- data/lib/ci/queue/build_record.rb +5 -5
- 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 +48 -0
- data/lib/ci/queue/redis/acknowledge.lua +7 -5
- data/lib/ci/queue/redis/base.rb +29 -6
- data/lib/ci/queue/redis/build_record.rb +29 -17
- data/lib/ci/queue/redis/heartbeat.lua +4 -4
- data/lib/ci/queue/redis/monitor.rb +14 -2
- data/lib/ci/queue/redis/requeue.lua +17 -10
- data/lib/ci/queue/redis/reserve.lua +47 -8
- data/lib/ci/queue/redis/supervisor.rb +3 -3
- data/lib/ci/queue/redis/worker.rb +210 -27
- data/lib/ci/queue/static.rb +5 -5
- data/lib/ci/queue/version.rb +1 -1
- data/lib/ci/queue.rb +27 -0
- data/lib/minitest/queue/build_status_recorder.rb +4 -4
- 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 +97 -22
- data/lib/minitest/queue/test_data.rb +15 -2
- data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
- data/lib/minitest/queue.rb +278 -10
- data/lib/rspec/queue/build_status_recorder.rb +4 -2
- data/lib/rspec/queue.rb +6 -2
- metadata +38 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'ci/queue/static'
|
|
3
3
|
require 'concurrent/set'
|
|
4
|
+
require 'concurrent/map'
|
|
4
5
|
|
|
5
6
|
module CI
|
|
6
7
|
module Queue
|
|
@@ -13,11 +14,13 @@ module CI
|
|
|
13
14
|
self.max_sleep_time = 2
|
|
14
15
|
|
|
15
16
|
class Worker < Base
|
|
16
|
-
|
|
17
|
+
attr_accessor :entry_resolver
|
|
18
|
+
attr_reader :first_reserve_at
|
|
17
19
|
|
|
18
20
|
def initialize(redis, config)
|
|
19
21
|
@reserved_tests = Concurrent::Set.new
|
|
20
22
|
@shutdown_required = false
|
|
23
|
+
@first_reserve_at = nil
|
|
21
24
|
super(redis, config)
|
|
22
25
|
end
|
|
23
26
|
|
|
@@ -27,15 +30,65 @@ module CI
|
|
|
27
30
|
|
|
28
31
|
def populate(tests, random: Random.new)
|
|
29
32
|
@index = tests.map { |t| [t.id, t] }.to_h
|
|
30
|
-
|
|
31
|
-
push(
|
|
33
|
+
entries = Queue.shuffle(tests, random).map { |test| queue_entry_for(test) }
|
|
34
|
+
push(entries)
|
|
32
35
|
self
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def stream_populate(tests, random: Random.new, batch_size: 10_000)
|
|
39
|
+
batch_size = batch_size.to_i
|
|
40
|
+
batch_size = 1 if batch_size < 1
|
|
41
|
+
|
|
42
|
+
value = key('setup', worker_id)
|
|
43
|
+
_, status = redis.pipelined do |pipeline|
|
|
44
|
+
pipeline.set(key('master-status'), value, nx: true)
|
|
45
|
+
pipeline.get(key('master-status'))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if @master = (value == status)
|
|
49
|
+
@total = 0
|
|
50
|
+
puts "Worker elected as leader, streaming tests to the queue."
|
|
51
|
+
|
|
52
|
+
duration = measure do
|
|
53
|
+
start_streaming!
|
|
54
|
+
buffer = []
|
|
55
|
+
|
|
56
|
+
tests.each do |test|
|
|
57
|
+
buffer << test
|
|
58
|
+
|
|
59
|
+
if buffer.size >= batch_size
|
|
60
|
+
push_batch(buffer, random)
|
|
61
|
+
buffer.clear
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
push_batch(buffer, random) unless buffer.empty?
|
|
66
|
+
finalize_streaming
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts "Streamed #{@total} tests in #{duration.round(2)}s."
|
|
70
|
+
$stdout.flush
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
register
|
|
74
|
+
redis.expire(key('workers'), config.redis_ttl)
|
|
75
|
+
self
|
|
76
|
+
rescue *CONNECTION_ERRORS
|
|
77
|
+
raise if @master
|
|
78
|
+
end
|
|
79
|
+
|
|
35
80
|
def populated?
|
|
36
81
|
!!defined?(@index)
|
|
37
82
|
end
|
|
38
83
|
|
|
84
|
+
def total
|
|
85
|
+
return @total if defined?(@total) && @total
|
|
86
|
+
|
|
87
|
+
redis.get(key('total')).to_i
|
|
88
|
+
rescue *CONNECTION_ERRORS
|
|
89
|
+
@total || 0
|
|
90
|
+
end
|
|
91
|
+
|
|
39
92
|
def shutdown!
|
|
40
93
|
@shutdown_required = true
|
|
41
94
|
end
|
|
@@ -51,13 +104,18 @@ module CI
|
|
|
51
104
|
DEFAULT_SLEEP_SECONDS = 0.5
|
|
52
105
|
|
|
53
106
|
def poll
|
|
54
|
-
wait_for_master
|
|
107
|
+
wait_for_master(timeout: config.queue_init_timeout, allow_streaming: true)
|
|
55
108
|
attempt = 0
|
|
56
109
|
until shutdown_required? || config.circuit_breakers.any?(&:open?) || exhausted? || max_test_failed?
|
|
57
|
-
if
|
|
110
|
+
if entry = reserve
|
|
58
111
|
attempt = 0
|
|
59
|
-
yield
|
|
112
|
+
yield resolve_entry(entry)
|
|
60
113
|
else
|
|
114
|
+
if still_streaming?
|
|
115
|
+
raise LostMaster, "Streaming stalled for more than #{config.lazy_load_streaming_timeout}s" if streaming_stale?
|
|
116
|
+
sleep 0.1
|
|
117
|
+
next
|
|
118
|
+
end
|
|
61
119
|
# Adding exponential backoff to avoid hammering Redis
|
|
62
120
|
# we just stay online here in case a test gets retried or times out so we can afford to wait
|
|
63
121
|
sleep_time = [DEFAULT_SLEEP_SECONDS * (2 ** attempt), Redis.max_sleep_time].min
|
|
@@ -89,7 +147,8 @@ module CI
|
|
|
89
147
|
def retry_queue
|
|
90
148
|
failures = build.failed_tests.to_set
|
|
91
149
|
log = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
|
|
92
|
-
log.
|
|
150
|
+
log = log.map { |entry| CI::Queue::QueueEntry.test_id(entry) }
|
|
151
|
+
log.select! { |test_id| failures.include?(test_id) }
|
|
93
152
|
log.uniq!
|
|
94
153
|
log.reverse!
|
|
95
154
|
Retry.new(log, config, redis: redis)
|
|
@@ -103,23 +162,38 @@ module CI
|
|
|
103
162
|
@build ||= CI::Queue::Redis::BuildRecord.new(self, redis, config)
|
|
104
163
|
end
|
|
105
164
|
|
|
165
|
+
def file_loader
|
|
166
|
+
@file_loader ||= CI::Queue::FileLoader.new
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def worker_queue_length
|
|
170
|
+
redis.llen(key('worker', worker_id, 'queue'))
|
|
171
|
+
rescue *CONNECTION_ERRORS
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
106
175
|
def report_worker_error(error)
|
|
107
176
|
build.report_worker_error(error)
|
|
108
177
|
end
|
|
109
178
|
|
|
110
|
-
def acknowledge(
|
|
111
|
-
|
|
179
|
+
def acknowledge(entry, error: nil, pipeline: redis)
|
|
180
|
+
test_id = CI::Queue::QueueEntry.test_id(entry)
|
|
181
|
+
assert_reserved!(test_id)
|
|
182
|
+
entry = reserved_entries.fetch(test_id, entry)
|
|
183
|
+
unreserve_entry(test_id)
|
|
112
184
|
eval_script(
|
|
113
185
|
:acknowledge,
|
|
114
|
-
keys: [key('running'), key('processed'), key('owners'), key('error-reports')],
|
|
115
|
-
argv: [
|
|
186
|
+
keys: [key('running'), key('processed'), key('owners'), key('error-reports'), key('requeued-by')],
|
|
187
|
+
argv: [entry, error.to_s, config.redis_ttl],
|
|
116
188
|
pipeline: pipeline,
|
|
117
189
|
) == 1
|
|
118
190
|
end
|
|
119
191
|
|
|
120
|
-
def requeue(
|
|
121
|
-
|
|
122
|
-
|
|
192
|
+
def requeue(entry, offset: Redis.requeue_offset)
|
|
193
|
+
test_id = CI::Queue::QueueEntry.test_id(entry)
|
|
194
|
+
assert_reserved!(test_id)
|
|
195
|
+
entry = reserved_entries.fetch(test_id, entry)
|
|
196
|
+
unreserve_entry(test_id)
|
|
123
197
|
global_max_requeues = config.global_max_requeues(total)
|
|
124
198
|
|
|
125
199
|
requeued = config.max_requeues > 0 && global_max_requeues > 0 && eval_script(
|
|
@@ -132,11 +206,16 @@ module CI
|
|
|
132
206
|
key('worker', worker_id, 'queue'),
|
|
133
207
|
key('owners'),
|
|
134
208
|
key('error-reports'),
|
|
209
|
+
key('requeued-by'),
|
|
135
210
|
],
|
|
136
|
-
argv: [config.max_requeues, global_max_requeues,
|
|
211
|
+
argv: [config.max_requeues, global_max_requeues, entry, offset, config.redis_ttl],
|
|
137
212
|
) == 1
|
|
138
213
|
|
|
139
|
-
|
|
214
|
+
unless requeued
|
|
215
|
+
reserved_tests << test_id
|
|
216
|
+
reserved_entries[test_id] = entry
|
|
217
|
+
reserved_entry_ids[entry] = test_id
|
|
218
|
+
end
|
|
140
219
|
requeued
|
|
141
220
|
end
|
|
142
221
|
|
|
@@ -157,19 +236,118 @@ module CI
|
|
|
157
236
|
@reserved_tests ||= Concurrent::Set.new
|
|
158
237
|
end
|
|
159
238
|
|
|
239
|
+
def reserved_entries
|
|
240
|
+
@reserved_entries ||= Concurrent::Map.new
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def reserved_entry_ids
|
|
244
|
+
@reserved_entry_ids ||= Concurrent::Map.new
|
|
245
|
+
end
|
|
246
|
+
|
|
160
247
|
def worker_id
|
|
161
248
|
config.worker_id
|
|
162
249
|
end
|
|
163
250
|
|
|
164
|
-
def
|
|
165
|
-
unless reserved_tests.
|
|
166
|
-
raise ReservationError, "Acknowledged #{
|
|
251
|
+
def assert_reserved!(test_id)
|
|
252
|
+
unless reserved_tests.include?(test_id)
|
|
253
|
+
raise ReservationError, "Acknowledged #{test_id.inspect} but only #{reserved_tests.map(&:inspect).join(", ")} reserved"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def reserve_entry(entry)
|
|
258
|
+
test_id = CI::Queue::QueueEntry.test_id(entry)
|
|
259
|
+
reserved_tests << test_id
|
|
260
|
+
reserved_entries[test_id] = entry
|
|
261
|
+
reserved_entry_ids[entry] = test_id
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def unreserve_entry(test_id)
|
|
265
|
+
entry = reserved_entries.delete(test_id)
|
|
266
|
+
reserved_tests.delete(test_id)
|
|
267
|
+
reserved_entry_ids.delete(entry) if entry
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def queue_entry_for(test)
|
|
271
|
+
return test.queue_entry if test.respond_to?(:queue_entry)
|
|
272
|
+
return test.id if test.respond_to?(:id)
|
|
273
|
+
|
|
274
|
+
test
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def resolve_entry(entry)
|
|
278
|
+
test_id = reserved_entry_ids[entry] || CI::Queue::QueueEntry.test_id(entry)
|
|
279
|
+
if populated?
|
|
280
|
+
return index[test_id] if index.key?(test_id)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
return entry_resolver.call(entry) if entry_resolver
|
|
284
|
+
|
|
285
|
+
entry
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def still_streaming?
|
|
289
|
+
master_status == 'streaming'
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def streaming_stale?
|
|
293
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
294
|
+
updated_at = redis.get(key('streaming-updated-at'))
|
|
295
|
+
return true unless updated_at
|
|
296
|
+
|
|
297
|
+
(CI::Queue.time_now.to_f - updated_at.to_f) > timeout
|
|
298
|
+
rescue *CONNECTION_ERRORS
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def start_streaming!
|
|
303
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
304
|
+
with_redis_timeout(5) do
|
|
305
|
+
redis.multi do |transaction|
|
|
306
|
+
transaction.set(key('total'), 0)
|
|
307
|
+
transaction.set(key('master-status'), 'streaming')
|
|
308
|
+
transaction.set(key('streaming-updated-at'), CI::Queue.time_now.to_f)
|
|
309
|
+
transaction.expire(key('streaming-updated-at'), timeout)
|
|
310
|
+
transaction.expire(key('queue'), config.redis_ttl)
|
|
311
|
+
transaction.expire(key('total'), config.redis_ttl)
|
|
312
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def push_batch(tests, random)
|
|
318
|
+
# Use plain shuffle instead of Queue.shuffle — the custom shuffler expects
|
|
319
|
+
# test objects with .id, but streaming entries are pre-formatted strings.
|
|
320
|
+
entries = tests.shuffle(random: random).map { |test| queue_entry_for(test) }
|
|
321
|
+
return if entries.empty?
|
|
322
|
+
|
|
323
|
+
@total += entries.size
|
|
324
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
325
|
+
redis.multi do |transaction|
|
|
326
|
+
transaction.lpush(key('queue'), entries)
|
|
327
|
+
transaction.incrby(key('total'), entries.size)
|
|
328
|
+
transaction.set(key('master-status'), 'streaming')
|
|
329
|
+
transaction.set(key('streaming-updated-at'), CI::Queue.time_now.to_f)
|
|
330
|
+
transaction.expire(key('streaming-updated-at'), timeout)
|
|
331
|
+
transaction.expire(key('queue'), config.redis_ttl)
|
|
332
|
+
transaction.expire(key('total'), config.redis_ttl)
|
|
333
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def finalize_streaming
|
|
338
|
+
redis.multi do |transaction|
|
|
339
|
+
transaction.set(key('master-status'), 'ready')
|
|
340
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
341
|
+
transaction.del(key('streaming-updated-at'))
|
|
167
342
|
end
|
|
168
343
|
end
|
|
169
344
|
|
|
170
345
|
def reserve
|
|
171
|
-
(try_to_reserve_lost_test || try_to_reserve_test).tap do |
|
|
172
|
-
|
|
346
|
+
(try_to_reserve_lost_test || try_to_reserve_test).tap do |entry|
|
|
347
|
+
if entry
|
|
348
|
+
@first_reserve_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
349
|
+
reserve_entry(entry)
|
|
350
|
+
end
|
|
173
351
|
end
|
|
174
352
|
end
|
|
175
353
|
|
|
@@ -182,8 +360,10 @@ module CI
|
|
|
182
360
|
key('processed'),
|
|
183
361
|
key('worker', worker_id, 'queue'),
|
|
184
362
|
key('owners'),
|
|
363
|
+
key('requeued-by'),
|
|
364
|
+
key('workers'),
|
|
185
365
|
],
|
|
186
|
-
argv: [CI::Queue.time_now.to_f],
|
|
366
|
+
argv: [CI::Queue.time_now.to_f, Redis.requeue_offset],
|
|
187
367
|
)
|
|
188
368
|
end
|
|
189
369
|
|
|
@@ -194,7 +374,7 @@ module CI
|
|
|
194
374
|
:reserve_lost,
|
|
195
375
|
keys: [
|
|
196
376
|
key('running'),
|
|
197
|
-
key('
|
|
377
|
+
key('processed'),
|
|
198
378
|
key('worker', worker_id, 'queue'),
|
|
199
379
|
key('owners'),
|
|
200
380
|
],
|
|
@@ -202,14 +382,17 @@ module CI
|
|
|
202
382
|
)
|
|
203
383
|
|
|
204
384
|
if lost_test
|
|
205
|
-
build.record_warning(Warnings::RESERVED_LOST_TEST, test: lost_test, timeout: config.timeout)
|
|
385
|
+
build.record_warning(Warnings::RESERVED_LOST_TEST, test: CI::Queue::QueueEntry.test_id(lost_test), timeout: config.timeout)
|
|
386
|
+
if CI::Queue.debug?
|
|
387
|
+
$stderr.puts "[ci-queue][reserve_lost] worker=#{worker_id} test_id=#{CI::Queue::QueueEntry.test_id(lost_test)}"
|
|
388
|
+
end
|
|
206
389
|
end
|
|
207
390
|
|
|
208
391
|
lost_test
|
|
209
392
|
end
|
|
210
393
|
|
|
211
|
-
def push(
|
|
212
|
-
@total =
|
|
394
|
+
def push(entries)
|
|
395
|
+
@total = entries.size
|
|
213
396
|
|
|
214
397
|
# We set a unique value (worker_id) and read it back to make "SET if Not eXists" idempotent in case of a retry.
|
|
215
398
|
value = key('setup', worker_id)
|
|
@@ -227,7 +410,7 @@ module CI
|
|
|
227
410
|
with_redis_timeout(5) do
|
|
228
411
|
redis.without_reconnect do
|
|
229
412
|
redis.multi do |transaction|
|
|
230
|
-
transaction.lpush(key('queue'),
|
|
413
|
+
transaction.lpush(key('queue'), entries) unless entries.empty?
|
|
231
414
|
transaction.set(key('total'), @total)
|
|
232
415
|
transaction.set(key('master-status'), 'ready')
|
|
233
416
|
|
data/lib/ci/queue/static.rb
CHANGED
|
@@ -125,12 +125,12 @@ module CI
|
|
|
125
125
|
test_failed >= config.max_test_failed
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
-
def requeue(
|
|
129
|
-
|
|
130
|
-
return false unless should_requeue?(
|
|
128
|
+
def requeue(entry)
|
|
129
|
+
test_id = CI::Queue::QueueEntry.test_id(entry)
|
|
130
|
+
return false unless should_requeue?(test_id)
|
|
131
131
|
|
|
132
|
-
requeues[
|
|
133
|
-
@queue.unshift(
|
|
132
|
+
requeues[test_id] += 1
|
|
133
|
+
@queue.unshift(test_id)
|
|
134
134
|
true
|
|
135
135
|
end
|
|
136
136
|
|
data/lib/ci/queue/version.rb
CHANGED
data/lib/ci/queue.rb
CHANGED
|
@@ -14,6 +14,9 @@ require 'ci/queue/static'
|
|
|
14
14
|
require 'ci/queue/file'
|
|
15
15
|
require 'ci/queue/grind'
|
|
16
16
|
require 'ci/queue/bisect'
|
|
17
|
+
require 'ci/queue/queue_entry'
|
|
18
|
+
require 'ci/queue/class_resolver'
|
|
19
|
+
require 'ci/queue/file_loader'
|
|
17
20
|
|
|
18
21
|
module CI
|
|
19
22
|
module Queue
|
|
@@ -22,6 +25,18 @@ module CI
|
|
|
22
25
|
attr_accessor :shuffler, :requeueable
|
|
23
26
|
|
|
24
27
|
Error = Class.new(StandardError)
|
|
28
|
+
ClassNotFoundError = Class.new(Error)
|
|
29
|
+
|
|
30
|
+
class FileLoadError < Error
|
|
31
|
+
attr_reader :file_path, :original_error
|
|
32
|
+
|
|
33
|
+
def initialize(file_path, original_error)
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
@original_error = original_error
|
|
36
|
+
super("Failed to load #{file_path}: #{original_error.class}: #{original_error.message}")
|
|
37
|
+
set_backtrace(original_error.backtrace)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
25
40
|
|
|
26
41
|
module Warnings
|
|
27
42
|
RESERVED_LOST_TEST = :RESERVED_LOST_TEST
|
|
@@ -48,6 +63,17 @@ module CI
|
|
|
48
63
|
end
|
|
49
64
|
end
|
|
50
65
|
|
|
66
|
+
def debug?
|
|
67
|
+
return @debug if defined?(@debug)
|
|
68
|
+
|
|
69
|
+
value = ENV['CI_QUEUE_DEBUG']
|
|
70
|
+
@debug = !!(value && !value.strip.empty? && !%w[0 false].include?(value.strip.downcase))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_debug!
|
|
74
|
+
remove_instance_variable(:@debug) if defined?(@debug)
|
|
75
|
+
end
|
|
76
|
+
|
|
51
77
|
def from_uri(url, config)
|
|
52
78
|
uri = URI(url)
|
|
53
79
|
implementation = case uri.scheme
|
|
@@ -65,3 +91,4 @@ module CI
|
|
|
65
91
|
end
|
|
66
92
|
end
|
|
67
93
|
end
|
|
94
|
+
|
|
@@ -40,15 +40,15 @@ module Minitest
|
|
|
40
40
|
self.total_time = Minitest.clock_time - start_time
|
|
41
41
|
|
|
42
42
|
# Determine what type of result this is and record it
|
|
43
|
-
|
|
43
|
+
entry = test.queue_entry
|
|
44
44
|
delta = delta_for(test)
|
|
45
45
|
|
|
46
46
|
acknowledged = if (test.failure || test.error?) && !test.skipped?
|
|
47
|
-
build.record_error(
|
|
47
|
+
build.record_error(entry, dump(test), stat_delta: delta)
|
|
48
48
|
elsif test.requeued?
|
|
49
|
-
build.record_requeue(
|
|
49
|
+
build.record_requeue(entry)
|
|
50
50
|
else
|
|
51
|
-
build.record_success(
|
|
51
|
+
build.record_success(entry, skip_flaky_record: test.skipped?)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
if acknowledged
|
|
@@ -136,9 +136,9 @@ module Minitest
|
|
|
136
136
|
result[:time] = 0
|
|
137
137
|
tests.each do |test|
|
|
138
138
|
result[:"#{result(test)}_count"] += 1
|
|
139
|
-
result[:assertion_count] += test.assertions
|
|
139
|
+
result[:assertion_count] += test.assertions || 0
|
|
140
140
|
result[:test_count] += 1
|
|
141
|
-
result[:time] += test.time
|
|
141
|
+
result[:time] += test.time || 0
|
|
142
142
|
end
|
|
143
143
|
result
|
|
144
144
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Queue
|
|
5
|
+
class LazyEntryResolver
|
|
6
|
+
def initialize(loader:, resolver:)
|
|
7
|
+
@loader = loader
|
|
8
|
+
@resolver = resolver
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(entry)
|
|
12
|
+
parsed = CI::Queue::QueueEntry.parse(entry)
|
|
13
|
+
class_name, method_name = parsed.fetch(:test_id).split('#', 2)
|
|
14
|
+
if CI::Queue::QueueEntry.load_error_payload?(parsed[:file_path])
|
|
15
|
+
payload = CI::Queue::QueueEntry.decode_load_error(parsed[:file_path])
|
|
16
|
+
if payload
|
|
17
|
+
error = StandardError.new("#{payload['error_class']}: #{payload['error_message']}")
|
|
18
|
+
error.set_backtrace(payload['backtrace']) if payload['backtrace']
|
|
19
|
+
load_error = CI::Queue::FileLoadError.new(payload['file_path'], error)
|
|
20
|
+
return Minitest::Queue::LazySingleExample.new(
|
|
21
|
+
class_name,
|
|
22
|
+
method_name,
|
|
23
|
+
payload['file_path'],
|
|
24
|
+
loader: @loader,
|
|
25
|
+
resolver: @resolver,
|
|
26
|
+
load_error: load_error,
|
|
27
|
+
queue_entry: entry,
|
|
28
|
+
)
|
|
29
|
+
else
|
|
30
|
+
error = StandardError.new("Corrupt load error payload for #{class_name}##{method_name}")
|
|
31
|
+
load_error = CI::Queue::FileLoadError.new("unknown", error)
|
|
32
|
+
return Minitest::Queue::LazySingleExample.new(
|
|
33
|
+
class_name,
|
|
34
|
+
method_name,
|
|
35
|
+
nil,
|
|
36
|
+
loader: @loader,
|
|
37
|
+
resolver: @resolver,
|
|
38
|
+
load_error: load_error,
|
|
39
|
+
queue_entry: entry,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Minitest::Queue::LazySingleExample.new(
|
|
45
|
+
class_name,
|
|
46
|
+
method_name,
|
|
47
|
+
parsed[:file_path],
|
|
48
|
+
loader: @loader,
|
|
49
|
+
resolver: @resolver,
|
|
50
|
+
queue_entry: entry,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -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
|