ci-queue 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile.lock +59 -47
  4. data/README.md +87 -0
  5. data/ci-queue.gemspec +3 -1
  6. data/lib/ci/queue/build_record.rb +5 -5
  7. data/lib/ci/queue/class_resolver.rb +38 -0
  8. data/lib/ci/queue/configuration.rb +62 -1
  9. data/lib/ci/queue/file_loader.rb +101 -0
  10. data/lib/ci/queue/queue_entry.rb +48 -0
  11. data/lib/ci/queue/redis/acknowledge.lua +7 -5
  12. data/lib/ci/queue/redis/base.rb +29 -6
  13. data/lib/ci/queue/redis/build_record.rb +29 -17
  14. data/lib/ci/queue/redis/heartbeat.lua +4 -4
  15. data/lib/ci/queue/redis/monitor.rb +14 -2
  16. data/lib/ci/queue/redis/requeue.lua +17 -10
  17. data/lib/ci/queue/redis/reserve.lua +47 -8
  18. data/lib/ci/queue/redis/supervisor.rb +3 -3
  19. data/lib/ci/queue/redis/worker.rb +210 -27
  20. data/lib/ci/queue/static.rb +5 -5
  21. data/lib/ci/queue/version.rb +1 -1
  22. data/lib/ci/queue.rb +27 -0
  23. data/lib/minitest/queue/build_status_recorder.rb +4 -4
  24. data/lib/minitest/queue/junit_reporter.rb +2 -2
  25. data/lib/minitest/queue/lazy_entry_resolver.rb +55 -0
  26. data/lib/minitest/queue/lazy_test_discovery.rb +169 -0
  27. data/lib/minitest/queue/local_requeue_reporter.rb +11 -0
  28. data/lib/minitest/queue/order_reporter.rb +9 -2
  29. data/lib/minitest/queue/queue_population_strategy.rb +176 -0
  30. data/lib/minitest/queue/runner.rb +97 -22
  31. data/lib/minitest/queue/test_data.rb +15 -2
  32. data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
  33. data/lib/minitest/queue.rb +278 -10
  34. data/lib/rspec/queue/build_status_recorder.rb +4 -2
  35. data/lib/rspec/queue.rb +6 -2
  36. metadata +38 -3
@@ -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
- attr_reader :total
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
- tests = Queue.shuffle(tests, random)
31
- push(tests.map(&:id))
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 test = reserve
110
+ if entry = reserve
58
111
  attempt = 0
59
- yield index.fetch(test)
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.select! { |id| failures.include?(id) }
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(test_key, error: nil, pipeline: redis)
111
- raise_on_mismatching_test(test_key)
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: [test_key, error.to_s, config.redis_ttl],
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(test, offset: Redis.requeue_offset)
121
- test_key = test.id
122
- raise_on_mismatching_test(test_key)
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, test_key, offset],
211
+ argv: [config.max_requeues, global_max_requeues, entry, offset, config.redis_ttl],
137
212
  ) == 1
138
213
 
139
- reserved_tests << test_key unless requeued
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 raise_on_mismatching_test(test)
165
- unless reserved_tests.delete?(test)
166
- raise ReservationError, "Acknowledged #{test.inspect} but only #{reserved_tests.map(&:inspect).join(", ")} reserved"
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 |test|
172
- reserved_tests << test if test
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('completed'),
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(tests)
212
- @total = tests.size
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'), tests) unless tests.empty?
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
 
@@ -125,12 +125,12 @@ module CI
125
125
  test_failed >= config.max_test_failed
126
126
  end
127
127
 
128
- def requeue(test)
129
- test_key = test.id
130
- return false unless should_requeue?(test_key)
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[test_key] += 1
133
- @queue.unshift(test_key)
132
+ requeues[test_id] += 1
133
+ @queue.unshift(test_id)
134
134
  true
135
135
  end
136
136
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CI
4
4
  module Queue
5
- VERSION = '0.82.0'
5
+ VERSION = '0.84.0'
6
6
  DEV_SCRIPTS_ROOT = ::File.expand_path('../../../../../redis', __FILE__)
7
7
  RELEASE_SCRIPTS_ROOT = ::File.expand_path('../redis', __FILE__)
8
8
  end
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
- test_id = "#{test.klass}##{test.name}"
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(test_id, dump(test), stat_delta: delta)
47
+ build.record_error(entry, dump(test), stat_delta: delta)
48
48
  elsif test.requeued?
49
- build.record_requeue(test_id)
49
+ build.record_requeue(entry)
50
50
  else
51
- build.record_success(test_id, skip_flaky_record: test.skipped?)
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