Skip to content

Commit 0f7a10a

Browse files
authored
Merge pull request #99 from figma/dpashkevich/worker-history-retry-selection
ruby: Replay worker history on retries
2 parents 271c775 + e52a7b8 commit 0f7a10a

12 files changed

Lines changed: 285 additions & 16 deletions

File tree

‎ruby/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ 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 reconstruct process-local artifacts after a distributed worker is retried, replay every test represented by that worker's reservation history:
42+
43+
```bash
44+
minitest-queue --queue redis://example.com \
45+
--retry-selection worker-history \
46+
run -Itest test/**/*_test.rb
47+
```
48+
49+
Worker-history retries require the retry to retain its worker ID and queue build ID. Missing reservation history or suite chunk metadata fails the retry. The replay uses a local retry queue and does not rejoin the shared queue, so surviving workers must drain any remaining work. When centralized reporting is used, restarting every worker before the shared queue is exhausted fails the build rather than accepting an incomplete run.
50+
4151

4252
If you'd like to centralize the error reporting you can do so with:
4353

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

Lines changed: 4 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_selection
1819
attr_reader :circuit_breakers
1920
attr_writer :seed, :build_id
2021
attr_writer :queue_init_timeout, :report_timeout, :inactive_workers_timeout
@@ -66,7 +67,8 @@ def initialize(
6667
branch: nil,
6768
timing_redis_url: nil,
6869
heartbeat_grace_period: 30,
69-
heartbeat_interval: 10
70+
heartbeat_interval: 10,
71+
retry_selection: :failed_tests
7072
)
7173
@build_id = build_id
7274
@circuit_breakers = [CircuitBreaker::Disabled]
@@ -105,6 +107,7 @@ def initialize(
105107
@write_duration_averages = false
106108
@heartbeat_grace_period = heartbeat_grace_period
107109
@heartbeat_interval = heartbeat_interval
110+
@retry_selection = retry_selection
108111
end
109112

110113
def queue_init_timeout

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ module CI
1717
module Queue
1818
module Redis
1919
Error = Class.new(StandardError)
20+
IncompleteRetry = Class.new(Error)
2021
LostMaster = Class.new(Error)
22+
WorkerHistoryError = Class.new(Error)
2123

2224
class << self
2325

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,27 @@ module CI
33
module Queue
44
module Redis
55
class Retry < Static
6-
def initialize(tests, config, redis:)
6+
def initialize(tests, config, redis:, require_exhaustion: false)
77
@redis = redis
8+
@require_exhaustion = require_exhaustion
89
super(tests, config)
910
end
1011

1112
def build
1213
@build ||= CI::Queue::Redis::BuildRecord.new(self, redis, config)
1314
end
1415

16+
def poll
17+
super
18+
return unless require_exhaustion
19+
return if exhausted?
20+
21+
raise IncompleteRetry, 'Worker history replay stopped before completion'
22+
end
23+
1524
private
1625

17-
attr_reader :redis
26+
attr_reader :redis, :require_exhaustion
1827
end
1928
end
2029
end

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

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,23 @@ def retrying?
145145
end
146146
end
147147

148-
def retry_queue
149-
failures = build.failed_tests.to_set
150-
log = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
151-
log.select! { |id| failures.include?(id) }
152-
log.uniq!
153-
log.reverse!
154-
Retry.new(log, config, redis: redis)
148+
def retry_queue(selection: :failed_tests)
149+
reservations = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
150+
test_ids = case selection
151+
when :failed_tests
152+
failed_test_ids(reservations)
153+
when :worker_history
154+
worker_history_test_ids(reservations)
155+
else
156+
raise ArgumentError, "Unknown retry selection: #{selection.inspect}"
157+
end
158+
159+
Retry.new(
160+
test_ids,
161+
config,
162+
redis: redis,
163+
require_exhaustion: selection == :worker_history
164+
)
155165
end
156166

157167
def supervisor
@@ -525,6 +535,31 @@ def chunk_id?(id)
525535
id.include?(':chunk_')
526536
end
527537

538+
def failed_test_ids(reservations)
539+
failures = build.failed_tests.to_set
540+
reservations.select { |id| failures.include?(id) }.uniq.reverse
541+
end
542+
543+
def worker_history_test_ids(reservations)
544+
raise WorkerHistoryError, "Reservation history is missing for worker #{worker_id}" if reservations.empty?
545+
546+
reservations.reverse.flat_map { |id| expand_reservation(id) }.uniq
547+
end
548+
549+
def expand_reservation(id)
550+
return [id] unless chunk_id?(id)
551+
552+
chunk_json = redis.get(key('chunk', id))
553+
raise WorkerHistoryError, "Chunk metadata is missing for #{id}" unless chunk_json
554+
555+
test_ids = CI::Queue::TestChunk.from_json(id, chunk_json).test_ids
556+
raise WorkerHistoryError, "Chunk metadata contains no tests for #{id}" if test_ids.empty?
557+
558+
test_ids
559+
rescue JSON::ParserError => e
560+
raise WorkerHistoryError, "Chunk metadata is invalid for #{id}: #{e.message}"
561+
end
562+
528563
def resolve_executable(id)
529564
# Detect chunk by ID pattern
530565
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,11 @@ def initialize(tests, config)
2222
@config = config
2323
@progress = 0
2424
@total = tests.size
25+
@shutdown_required = false
26+
end
27+
28+
def shutdown!
29+
@shutdown_required = true
2530
end
2631

2732
def distributed?
@@ -66,7 +71,7 @@ def size
6671
end
6772

6873
def poll
69-
while config.circuit_breakers.none?(&:open?) && !max_test_failed? && test = @queue.shift
74+
while !@shutdown_required && config.circuit_breakers.none?(&:open?) && !max_test_failed? && (test = @queue.shift)
7075
yield index.fetch(test)
7176
end
7277
end

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,15 @@ 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
57+
retry_queue = selected_retry_queue
5858
if retry_queue.exhausted?
5959
puts "The retry queue does not contain any failure, we'll process the main queue instead."
6060
else
61-
puts "Retrying failed tests."
61+
if queue_config.retry_selection == :worker_history
62+
puts "Replaying #{retry_queue.total} tests from worker history."
63+
else
64+
puts "Retrying failed tests."
65+
end
6266
self.queue = retry_queue
6367
end
6468
end
@@ -494,6 +498,14 @@ def parser
494498
queue_config.worker_id = worker_id
495499
end
496500

501+
help = <<~EOS
502+
Select tests for a retried worker: failed-tests (default) or worker-history.
503+
EOS
504+
opts.separator ""
505+
opts.on('--retry-selection SELECTION', %w[failed-tests worker-history], help) do |selection|
506+
queue_config.retry_selection = selection.tr('-', '_').to_sym
507+
end
508+
497509
help = <<~EOS
498510
Defines how many time a single test can be requeued.
499511
Defaults to 0.
@@ -741,6 +753,19 @@ def retry?
741753
ENV["BUILDKITE_RETRY_COUNT"].to_i > 0 ||
742754
ENV["SEMAPHORE_PIPELINE_RERUN"] == "true"
743755
end
756+
757+
def selected_retry_queue
758+
return queue.retry_queue if queue_config.retry_selection == :failed_tests
759+
760+
unless queue_config.retry_selection == :worker_history
761+
abort! "Unknown retry selection: #{queue_config.retry_selection.inspect}"
762+
end
763+
abort! 'Worker-history retry selection requires a distributed Redis queue' unless queue.distributed?
764+
765+
queue.retry_queue(selection: :worker_history)
766+
rescue CI::Queue::Redis::WorkerHistoryError => e
767+
abort! e.message
768+
end
744769
end
745770
end
746771
end

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ def test_redis_ttl_defaults
7373
assert_equal(28_800, config.redis_ttl)
7474
end
7575

76+
def test_retry_selection_defaults_to_failed_tests
77+
config = Configuration.new
78+
79+
assert_equal :failed_tests, config.retry_selection
80+
end
81+
7682
def test_redis_ttl_from_env
7783
config = Configuration.from_env(
7884
"CI_QUEUE_REDIS_TTL" => "14400"

‎ruby/test/ci/queue/redis/worker_chunk_test.rb‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,65 @@ def test_populate_with_many_chunks_uses_batching
243243
end
244244
end
245245

246+
def test_worker_history_retry_expands_chunks_and_deduplicates_tests
247+
tests = create_mock_tests(['TestA#test_1', 'TestA#test_2', 'TestB#test_1'])
248+
chunk = CI::Queue::TestChunk.new(
249+
'TestA:chunk_0',
250+
'TestA',
251+
['TestA#test_1', 'TestA#test_2'],
252+
2000.0
253+
)
254+
255+
@worker.stub(:reorder_tests, [chunk, tests.last]) do
256+
@worker.populate(tests)
257+
end
258+
259+
history_key = 'build:42:worker:1:queue'
260+
[chunk.id, tests.last.id, chunk.id, tests[1].id].each do |id|
261+
@redis.lpush(history_key, id)
262+
end
263+
264+
retry_queue = @worker.retry_queue(selection: :worker_history)
265+
retry_queue.populate(tests)
266+
267+
assert_equal ['TestA#test_1', 'TestA#test_2', 'TestB#test_1'], retry_queue.to_a.map(&:id)
268+
end
269+
270+
def test_worker_history_retry_requires_chunk_metadata
271+
@redis.lpush('build:42:worker:1:queue', 'TestA:chunk_0')
272+
273+
error = assert_raises(CI::Queue::Redis::WorkerHistoryError) do
274+
@worker.retry_queue(selection: :worker_history)
275+
end
276+
277+
assert_equal 'Chunk metadata is missing for TestA:chunk_0', error.message
278+
end
279+
280+
def test_worker_history_retry_rejects_empty_chunks
281+
@redis.set(
282+
'build:42:chunk:TestA:chunk_0',
283+
CI::Queue::TestChunk.new('TestA:chunk_0', 'TestA', [], 0).to_json
284+
)
285+
@redis.lpush('build:42:worker:1:queue', 'TestA:chunk_0')
286+
287+
error = assert_raises(CI::Queue::Redis::WorkerHistoryError) do
288+
@worker.retry_queue(selection: :worker_history)
289+
end
290+
291+
assert_equal 'Chunk metadata contains no tests for TestA:chunk_0', error.message
292+
end
293+
294+
def test_worker_history_retry_rejects_invalid_chunk_metadata
295+
@redis.set('build:42:chunk:TestA:chunk_0', '{')
296+
@redis.lpush('build:42:worker:1:queue', 'TestA:chunk_0')
297+
298+
error = assert_raises(CI::Queue::Redis::WorkerHistoryError) do
299+
@worker.retry_queue(selection: :worker_history)
300+
end
301+
302+
assert_match 'Chunk metadata is invalid for TestA:chunk_0:', error.message
303+
end
304+
246305
private
247306

248307
def create_mock_tests(test_ids)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,43 @@ def test_retry_queue_with_all_tests_passing_2
6464
assert_equal retry_test_order, retry_test_order
6565
end
6666

67+
def test_retry_queue_with_worker_history
68+
original_order = poll(@queue)
69+
retry_queue = populate(@queue.retry_queue(selection: :worker_history))
70+
71+
assert_equal original_order, poll(retry_queue)
72+
end
73+
74+
def test_worker_history_retry_requires_reservations
75+
error = assert_raises(CI::Queue::Redis::WorkerHistoryError) do
76+
@queue.retry_queue(selection: :worker_history)
77+
end
78+
79+
assert_equal 'Reservation history is missing for worker 1', error.message
80+
end
81+
82+
def test_worker_history_retry_requires_complete_replay
83+
poll(@queue)
84+
retry_queue = populate(@queue.retry_queue(selection: :worker_history))
85+
86+
error = assert_raises(CI::Queue::Redis::IncompleteRetry) do
87+
retry_queue.poll do
88+
retry_queue.shutdown!
89+
end
90+
end
91+
92+
assert_equal 'Worker history replay stopped before completion', error.message
93+
refute_predicate retry_queue, :exhausted?
94+
end
95+
96+
def test_retry_queue_rejects_unknown_selection
97+
error = assert_raises(ArgumentError) do
98+
@queue.retry_queue(selection: :everything)
99+
end
100+
101+
assert_equal 'Unknown retry selection: :everything', error.message
102+
end
103+
67104
def test_shutdown
68105
poll(@queue) do
69106
@queue.shutdown!

0 commit comments

Comments
 (0)