Skip to content

Commit cb156e1

Browse files
committed
Expose worker history retry mode
1 parent 1920fb0 commit cb156e1

8 files changed

Lines changed: 217 additions & 6 deletions

File tree

‎ruby/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ 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+
To recover artifacts lost with a retried distributed worker, replay every test
42+
reserved by that worker before resuming the shared queue:
43+
44+
```bash
45+
minitest-queue --queue redis://example.com \
46+
--retry-mode worker-history \
47+
--recovery-manifest log/ci-queue-recovery.json \
48+
run -Itest test/**/*_test.rb
49+
```
50+
51+
This mode requires the retry to retain its worker ID and queue build ID. It
52+
fails when the worker's reservation history or suite chunk metadata is missing.
53+
4154

4255
If you'd like to centralize the error reporting you can do so with:
4356

‎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 || 0,
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/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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
require 'fileutils'
4+
require 'json'
5+
require 'minitest/reporters'
6+
7+
module Minitest
8+
module Queue
9+
class RecoveryReporter < Minitest::Reporters::BaseReporter
10+
def initialize(path:, queue:, config:)
11+
super({})
12+
@path = path
13+
@queue = queue
14+
@config = config
15+
end
16+
17+
def report
18+
super
19+
return unless queue.exhausted?
20+
return if recovery? && !queue.replay_completed?
21+
22+
FileUtils.mkdir_p(File.dirname(path))
23+
File.write(path, JSON.pretty_generate(manifest))
24+
end
25+
26+
private
27+
28+
attr_reader :config, :path, :queue
29+
30+
def recovery?
31+
queue.is_a?(CI::Queue::WorkerHistoryRecovery)
32+
end
33+
34+
def manifest
35+
{
36+
schema_version: 1,
37+
worker_id: config.worker_id.to_s,
38+
retry_count: config.retry_count,
39+
history_items: recovery? ? queue.history_items : 0,
40+
replayed_tests: recovery? ? queue.replayed_tests : 0,
41+
resumed_shared_queue: recovery? && queue.resumed_shared_queue?,
42+
replay_completed: !recovery? || queue.replay_completed?
43+
}
44+
end
45+
end
46+
end
47+
end

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,16 @@ def run_command
5454
abort! "The test run is too old and can't be retried"
5555
end
5656
reset_counters
57-
retry_queue = queue.retry_queue
58-
if retry_queue.exhausted?
59-
puts "The retry queue does not contain any failure, we'll process the main queue instead."
57+
if worker_history_retry?
58+
recover_worker_history
6059
else
61-
puts "Retrying failed tests."
62-
self.queue = retry_queue
60+
retry_queue = queue.retry_queue
61+
if retry_queue.exhausted?
62+
puts "The retry queue does not contain any failure, we'll process the main queue instead."
63+
else
64+
puts "Retrying failed tests."
65+
self.queue = retry_queue
66+
end
6367
end
6468
end
6569

@@ -81,6 +85,13 @@ def run_command
8185
if queue_config.statsd_endpoint
8286
reporters << Minitest::Reporters::StatsdReporter.new(statsd_endpoint: queue_config.statsd_endpoint)
8387
end
88+
if queue_config.recovery_manifest
89+
reporters << RecoveryReporter.new(
90+
path: queue_config.recovery_manifest,
91+
queue: queue,
92+
config: queue_config
93+
)
94+
end
8495
Minitest.queue_reporters = reporters
8596

8697
trap('TERM') { Minitest.queue.shutdown! }
@@ -324,6 +335,22 @@ def reset_counters
324335
queue.build.reset_stats(BuildStatusRecorder::COUNTERS)
325336
end
326337

338+
def recover_worker_history
339+
unless queue.distributed? && queue.respond_to?(:worker_history)
340+
abort! 'Worker history recovery requires a distributed Redis queue'
341+
end
342+
343+
history = queue.worker_history
344+
puts "Replaying #{history.test_ids.size} tests from #{history.history_items} worker reservations."
345+
self.queue = CI::Queue::WorkerHistoryRecovery.new(queue, history)
346+
rescue CI::Queue::Redis::WorkerHistoryError => error
347+
abort! error.message
348+
end
349+
350+
def worker_history_retry?
351+
queue_config.retry_mode == :worker_history
352+
end
353+
327354
def populate_queue
328355
Minitest.queue.populate(Minitest.loaded_tests, random: ordering_seed, &:id)
329356
end
@@ -494,6 +521,22 @@ def parser
494521
queue_config.worker_id = worker_id
495522
end
496523

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

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,20 @@ 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+
end
43+
44+
def test_retry_defaults
45+
config = Configuration.new
46+
47+
assert_equal :failures, config.retry_mode
48+
assert_equal 0, config.retry_count
49+
assert_nil config.recovery_manifest
4050
end
4151

4252
def test_travis_defaults

‎ruby/test/integration/minitest_redis_test.rb‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,50 @@ def test_retry_success
220220
assert_equal 'All tests were ran already', output
221221
end
222222

223+
def test_worker_history_retry_writes_recovery_manifest
224+
Dir.mktmpdir do |directory|
225+
manifest_path = File.join(directory, 'recovery.json')
226+
run_worker_history_worker(manifest_path, retry_count: 0)
227+
228+
first_manifest = JSON.parse(File.read(manifest_path))
229+
assert_equal 0, first_manifest['retry_count']
230+
assert_equal 0, first_manifest['history_items']
231+
assert_equal 0, first_manifest['replayed_tests']
232+
refute first_manifest['resumed_shared_queue']
233+
assert first_manifest['replay_completed']
234+
235+
out, = run_worker_history_worker(manifest_path, retry_count: 1)
236+
237+
assert_includes out, 'Replaying 100 tests from 100 worker reservations.'
238+
retry_manifest = JSON.parse(File.read(manifest_path))
239+
assert_equal 1, retry_manifest['schema_version']
240+
assert_equal '1', retry_manifest['worker_id']
241+
assert_equal 1, retry_manifest['retry_count']
242+
assert_equal 100, retry_manifest['history_items']
243+
assert_equal 100, retry_manifest['replayed_tests']
244+
assert retry_manifest['resumed_shared_queue']
245+
assert retry_manifest['replay_completed']
246+
end
247+
end
248+
249+
def test_worker_history_retry_fails_without_worker_reservations
250+
run_worker_history_worker(nil, retry_count: 0, build_id: 'missing-history', worker_id: '1')
251+
252+
Dir.mktmpdir do |directory|
253+
manifest_path = File.join(directory, 'recovery.json')
254+
out, = run_worker_history_worker(
255+
manifest_path,
256+
retry_count: 1,
257+
build_id: 'missing-history',
258+
worker_id: '2'
259+
)
260+
261+
refute_predicate $?, :success?
262+
assert_includes out, 'Reservation history is missing for worker 2'
263+
refute_path_exists manifest_path
264+
end
265+
end
266+
223267
def test_retry_fails_when_test_run_is_expired
224268
out, err = capture_subprocess_io do
225269
system(
@@ -788,6 +832,28 @@ def test_utf8_tests_and_marshal
788832

789833
private
790834

835+
def run_worker_history_worker(manifest_path, retry_count:, build_id: 'worker-history', worker_id: '1')
836+
args = [
837+
@exe, 'run',
838+
'--queue', @redis_url,
839+
'--seed', 'foobar',
840+
'--build', build_id,
841+
'--worker', worker_id,
842+
'--timeout', '1',
843+
'--retry-mode', 'worker-history'
844+
]
845+
args.push('--recovery-manifest', manifest_path) if manifest_path
846+
args.push('-Itest', 'test/passing_test.rb')
847+
848+
capture_subprocess_io do
849+
system(
850+
{ 'BUILDKITE_RETRY_COUNT' => retry_count.to_s },
851+
*args,
852+
chdir: 'test/fixtures/'
853+
)
854+
end
855+
end
856+
791857
def normalize_xml(output)
792858
freeze_xml_timing(rewrite_paths(output))
793859
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
module Minitest
6+
module Queue
7+
class RecoveryReporterTest < Minitest::Test
8+
def test_does_not_write_manifest_for_incomplete_run
9+
Dir.mktmpdir do |directory|
10+
path = File.join(directory, 'recovery.json')
11+
queue = Struct.new(:exhausted?).new(false)
12+
config = CI::Queue::Configuration.new(worker_id: '17')
13+
reporter = RecoveryReporter.new(path: path, queue: queue, config: config)
14+
15+
reporter.start
16+
reporter.report
17+
18+
refute_path_exists path
19+
end
20+
end
21+
end
22+
end
23+
end

0 commit comments

Comments
 (0)