Skip to content

Commit 19c8ac7

Browse files
committed
ruby: Replay worker history with retry queue
1 parent 271c775 commit 19c8ac7

14 files changed

Lines changed: 401 additions & 6 deletions

File tree

‎ruby/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ minitest-queue --queue redis://example.com run -Itest test/**/*_test.rb
3838

3939
Additionally you can configure the requeue settings (see main README) with `--max-requeues` and `--requeue-tolerance`.
4040

41+
#### Replaying a retried worker for coverage
42+
43+
By default, a retried worker runs only tests recorded as failed. For process-local
44+
artifacts such as SimpleCov output, use worker-history mode to replay every test
45+
previously reserved by that worker:
46+
47+
```bash
48+
minitest-queue --queue redis://example.com run \
49+
--retry-mode worker-history \
50+
--recovery-manifest tmp/ci-queue-recovery.json \
51+
-Itest test/**/*_test.rb
52+
```
53+
54+
The replay uses the existing local retry queue and exits after the worker history
55+
is exhausted. It does not claim additional work from the shared Redis queue. The
56+
manifest is written atomically only after a complete run.
57+
4158

4259
If you'd like to centralize the error reporting you can do so with:
4360

‎ruby/lib/ci/queue/configuration.rb‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class Configuration
1515
attr_accessor :timing_redis_url
1616
attr_accessor :write_duration_averages
1717
attr_accessor :heartbeat_grace_period, :heartbeat_interval
18+
attr_accessor :retry_mode, :recovery_manifest, :retry_count
1819
attr_reader :circuit_breakers
1920
attr_writer :seed, :build_id
2021
attr_writer :queue_init_timeout, :report_timeout, :inactive_workers_timeout
@@ -30,6 +31,7 @@ def from_env(env)
3031
redis_ttl: env['CI_QUEUE_REDIS_TTL']&.to_i || 8 * 60 * 60,
3132
known_flaky_tests: load_known_flaky_tests(env['CI_QUEUE_KNOWN_FLAKY_TESTS']),
3233
branch: env['BUILDKITE_BRANCH'],
34+
retry_count: env['BUILDKITE_RETRY_COUNT'].to_i,
3335
)
3436
end
3537

@@ -66,7 +68,10 @@ def initialize(
6668
branch: nil,
6769
timing_redis_url: nil,
6870
heartbeat_grace_period: 30,
69-
heartbeat_interval: 10
71+
heartbeat_interval: 10,
72+
retry_mode: :failures,
73+
recovery_manifest: nil,
74+
retry_count: 0
7075
)
7176
@build_id = build_id
7277
@circuit_breakers = [CircuitBreaker::Disabled]
@@ -105,6 +110,9 @@ def initialize(
105110
@write_duration_averages = false
106111
@heartbeat_grace_period = heartbeat_grace_period
107112
@heartbeat_interval = heartbeat_interval
113+
@retry_mode = retry_mode
114+
@recovery_manifest = recovery_manifest
115+
@retry_count = retry_count
108116
end
109117

110118
def queue_init_timeout

‎ruby/lib/ci/queue/redis.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module Queue
1818
module Redis
1919
Error = Class.new(StandardError)
2020
LostMaster = Class.new(Error)
21+
WorkerHistoryError = Class.new(Error)
2122

2223
class << self
2324

‎ruby/lib/ci/queue/redis/retry.rb‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,30 @@ module CI
33
module Queue
44
module Redis
55
class Retry < Static
6-
def initialize(tests, config, redis:)
6+
attr_reader :history_items, :replayed_tests
7+
8+
def initialize(tests, config, redis:, history_items: 0, worker_history: false)
79
@redis = redis
10+
@history_items = history_items
11+
@replayed_tests = worker_history ? tests.size : 0
12+
@worker_history = worker_history
13+
@worker_history_complete = false
814
super(tests, config)
915
end
1016

17+
def worker_history?
18+
@worker_history
19+
end
20+
21+
def worker_history_complete?
22+
@worker_history_complete
23+
end
24+
25+
def poll(&block)
26+
super
27+
@worker_history_complete = exhausted? if worker_history?
28+
end
29+
1130
def build
1231
@build ||= CI::Queue::Redis::BuildRecord.new(self, redis, config)
1332
end

‎ruby/lib/ci/queue/redis/worker.rb‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ def retrying?
145145
end
146146
end
147147

148-
def retry_queue
148+
def retry_queue(scope: :failures)
149+
return worker_history_retry_queue if scope == :worker_history
150+
raise ArgumentError, "Unknown retry scope: #{scope}" unless scope == :failures
151+
149152
failures = build.failed_tests.to_set
150153
log = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
151154
log.select! { |id| failures.include?(id) }
@@ -264,6 +267,31 @@ def heartbeat(test_or_id = nil)
264267

265268
attr_reader :index
266269

270+
def worker_history_retry_queue
271+
reservations = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
272+
if reservations.empty?
273+
raise WorkerHistoryError, "Reservation history is missing for worker #{worker_id}"
274+
end
275+
276+
seen = {}
277+
test_ids = reservations.reverse_each.each_with_object([]) do |reservation_id, ids|
278+
expand_reservation(reservation_id).each do |test_id|
279+
next if seen[test_id]
280+
281+
seen[test_id] = true
282+
ids << test_id
283+
end
284+
end
285+
286+
Retry.new(
287+
test_ids,
288+
config,
289+
redis: redis,
290+
history_items: reservations.size,
291+
worker_history: true
292+
)
293+
end
294+
267295
# Runs a block while sending periodic heartbeats in a background thread.
268296
# This prevents other workers from stealing the test while it's being executed.
269297
def with_heartbeat(test_id)
@@ -525,6 +553,20 @@ def chunk_id?(id)
525553
id.include?(':chunk_')
526554
end
527555

556+
def expand_reservation(id)
557+
return [id] unless chunk_id?(id)
558+
559+
chunk_json = redis.get(key('chunk', id))
560+
raise WorkerHistoryError, "Chunk metadata is missing for #{id}" unless chunk_json
561+
562+
test_ids = CI::Queue::TestChunk.from_json(id, chunk_json).test_ids
563+
raise WorkerHistoryError, "Chunk metadata contains no tests for #{id}" if test_ids.empty?
564+
565+
test_ids
566+
rescue JSON::ParserError => error
567+
raise WorkerHistoryError, "Chunk metadata is invalid for #{id}: #{error.message}"
568+
end
569+
528570
def resolve_executable(id)
529571
# Detect chunk by ID pattern
530572
if chunk_id?(id)

‎ruby/lib/ci/queue/static.rb‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def initialize(tests, config)
2222
@config = config
2323
@progress = 0
2424
@total = tests.size
25+
@shutdown_required = false
2526
end
2627

2728
def distributed?
@@ -66,11 +67,15 @@ def size
6667
end
6768

6869
def poll
69-
while config.circuit_breakers.none?(&:open?) && !max_test_failed? && test = @queue.shift
70+
while !@shutdown_required && config.circuit_breakers.none?(&:open?) && !max_test_failed? && test = @queue.shift
7071
yield index.fetch(test)
7172
end
7273
end
7374

75+
def shutdown!
76+
@shutdown_required = true
77+
end
78+
7479
def exhausted?
7580
@queue.empty?
7681
end

‎ruby/lib/minitest/queue.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
require 'minitest/queue/grind_reporter'
1616
require 'minitest/queue/test_time_recorder'
1717
require 'minitest/queue/test_time_reporter'
18+
require 'minitest/queue/recovery_reporter'
1819

1920
module Minitest
2021
class Requeue < Skip
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
require 'fileutils'
4+
require 'json'
5+
require 'minitest/reporters'
6+
require 'tempfile'
7+
8+
module Minitest
9+
module Queue
10+
class RecoveryReporter < Minitest::Reporters::BaseReporter
11+
def initialize(path:, queue:, config:)
12+
super({})
13+
@path = path
14+
@queue = queue
15+
@config = config
16+
end
17+
18+
def report
19+
super
20+
return unless queue.exhausted?
21+
return if worker_history_retry? && !queue.worker_history_complete?
22+
23+
directory = File.dirname(path)
24+
FileUtils.mkdir_p(directory)
25+
temporary = Tempfile.new(['recovery', '.json'], directory)
26+
temporary.write(JSON.pretty_generate(manifest))
27+
temporary.flush
28+
temporary.fsync
29+
temporary.close
30+
File.rename(temporary.path, path)
31+
ensure
32+
temporary&.close!
33+
end
34+
35+
private
36+
37+
attr_reader :config, :path, :queue
38+
39+
def worker_history_retry?
40+
queue.respond_to?(:worker_history?) && queue.worker_history?
41+
end
42+
43+
def manifest
44+
{
45+
schema_version: 1,
46+
worker_id: config.worker_id.to_s,
47+
retry_count: config.retry_count,
48+
history_items: worker_history_retry? ? queue.history_items : 0,
49+
replayed_tests: worker_history_retry? ? queue.replayed_tests : 0,
50+
resumed_shared_queue: false,
51+
replay_completed: !worker_history_retry? || queue.worker_history_complete?
52+
}
53+
end
54+
end
55+
end
56+
end

‎ruby/lib/minitest/queue/runner.rb‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'minitest/queue'
55
require 'ci/queue'
66
require 'digest/md5'
7+
require 'fileutils'
78
require 'minitest/reporters/bisect_reporter'
89
require 'minitest/reporters/statsd_reporter'
910

@@ -49,16 +50,21 @@ def retry_command
4950

5051
def run_command
5152
require_worker_id!
53+
FileUtils.rm_f(queue_config.recovery_manifest) if queue_config.recovery_manifest
5254
if queue.retrying? || retry?
5355
if queue.expired?
5456
abort! "The test run is too old and can't be retried"
5557
end
5658
reset_counters
57-
retry_queue = queue.retry_queue
59+
retry_queue = retry_queue_for_retry
5860
if retry_queue.exhausted?
5961
puts "The retry queue does not contain any failure, we'll process the main queue instead."
6062
else
61-
puts "Retrying failed tests."
63+
if worker_history_retry?
64+
puts "Replaying #{retry_queue.replayed_tests} tests from #{retry_queue.history_items} worker reservations."
65+
else
66+
puts "Retrying failed tests."
67+
end
6268
self.queue = retry_queue
6369
end
6470
end
@@ -81,6 +87,13 @@ def run_command
8187
if queue_config.statsd_endpoint
8288
reporters << Minitest::Reporters::StatsdReporter.new(statsd_endpoint: queue_config.statsd_endpoint)
8389
end
90+
if queue_config.recovery_manifest
91+
reporters << RecoveryReporter.new(
92+
path: queue_config.recovery_manifest,
93+
queue: queue,
94+
config: queue_config
95+
)
96+
end
8497
Minitest.queue_reporters = reporters
8598

8699
trap('TERM') { Minitest.queue.shutdown! }
@@ -324,6 +337,22 @@ def reset_counters
324337
queue.build.reset_stats(BuildStatusRecorder::COUNTERS)
325338
end
326339

340+
def worker_history_retry?
341+
queue_config.retry_mode == :worker_history
342+
end
343+
344+
def retry_queue_for_retry
345+
if worker_history_retry?
346+
abort! 'Worker history recovery requires a distributed Redis queue' unless queue.distributed?
347+
348+
queue.retry_queue(scope: :worker_history)
349+
else
350+
queue.retry_queue
351+
end
352+
rescue CI::Queue::Redis::WorkerHistoryError => error
353+
abort! error.message
354+
end
355+
327356
def populate_queue
328357
Minitest.queue.populate(Minitest.loaded_tests, random: ordering_seed, &:id)
329358
end
@@ -494,6 +523,22 @@ def parser
494523
queue_config.worker_id = worker_id
495524
end
496525

526+
help = <<~EOS
527+
Retry behavior: failures (default) or worker-history.
528+
EOS
529+
opts.separator ""
530+
opts.on('--retry-mode MODE', %w[failures worker-history], help) do |mode|
531+
queue_config.retry_mode = mode.tr('-', '_').to_sym
532+
end
533+
534+
help = <<~EOS
535+
Write worker-history recovery details to a JSON file after a completed run.
536+
EOS
537+
opts.separator ""
538+
opts.on('--recovery-manifest PATH', help) do |path|
539+
queue_config.recovery_manifest = path
540+
end
541+
497542
help = <<~EOS
498543
Defines how many time a single test can be requeued.
499544
Defaults to 0.

‎ruby/test/ci/queue/configuration_test.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ def test_buildkite_defaults
3333
'BUILDKITE_BUILD_ID' => '9e08ef3c-d6e6-4a86-91dd-577ce5205b8e',
3434
'BUILDKITE_PARALLEL_JOB' => '12',
3535
'BUILDKITE_COMMIT' => 'faa647bbb8168a77cf338e7488c3f8445c3e6554',
36+
'BUILDKITE_RETRY_COUNT' => '2',
3637
)
3738
assert_equal '9e08ef3c-d6e6-4a86-91dd-577ce5205b8e', config.build_id
3839
assert_equal '12', config.worker_id
3940
assert_equal 'faa647bbb8168a77cf338e7488c3f8445c3e6554', config.seed
41+
assert_equal 2, config.retry_count
42+
assert_equal :failures, config.retry_mode
4043
end
4144

4245
def test_travis_defaults

0 commit comments

Comments
 (0)