Skip to content

Commit ecd4abc

Browse files
authored
Merge pull request #103 from figma/ckwarteng/recover-ci-queue-initialization
ruby / ci-queue: Recover queue initialization when the master exits
2 parents feb3e30 + 263461c commit ecd4abc

6 files changed

Lines changed: 282 additions & 52 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def wait_for_master(timeout: 120)
5959
(timeout * 10 + 1).to_i.times do
6060
return true if queue_initialized?
6161

62+
yield if block_given?
63+
return true if queue_initialized?
64+
6265
sleep 0.1
6366
end
6467
raise LostMaster, "The master worker (worker #{master_worker_id}) is still `#{master_status}` after #{timeout} seconds waiting."

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

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'ci/queue/static'
44
require 'concurrent/set'
5+
require 'securerandom'
56

67
module CI
78
module Queue
@@ -37,18 +38,8 @@ def populate(tests, random: Random.new)
3738
# All workers need an index of tests to resolve IDs
3839
@index = tests.map { |t| [t.id, t] }.to_h
3940
@total = tests.size
40-
41-
if acquire_master_role?
42-
executables = reorder_tests(tests, random: random)
43-
44-
chunks = executables.select { |e| e.is_a?(CI::Queue::TestChunk) }
45-
individual_tests = executables.reject { |e| e.is_a?(CI::Queue::TestChunk) }
46-
47-
store_chunk_metadata(chunks) if chunks.any?
48-
49-
all_ids = chunks.map(&:id) + individual_tests.map(&:id)
50-
push(all_ids)
51-
end
41+
@random = random
42+
initialize_queue
5243

5344
register_worker_presence
5445

@@ -76,7 +67,7 @@ def idle?
7667
end
7768

7869
def poll
79-
wait_for_master
70+
wait_for_master(timeout: config.queue_init_timeout) { initialize_queue }
8071
if master?
8172
warn "Worker #{worker_id} is the master"
8273
else
@@ -447,20 +438,47 @@ def try_to_reserve_lost_test
447438
lost_test
448439
end
449440

441+
def initialize_queue
442+
return unless acquire_master_role?
443+
444+
executables = reorder_tests(index.values, random: @random)
445+
chunks, individual_tests = executables.partition { |e| e.is_a?(CI::Queue::TestChunk) }
446+
# An initializer may outlive its setup marker. Give its chunks unique
447+
# IDs so staging metadata cannot overwrite another initializer's data.
448+
initialization_id = SecureRandom.hex(8)
449+
chunks = chunks.map do |chunk|
450+
CI::Queue::TestChunk.new(
451+
"#{chunk.id}:#{initialization_id}", chunk.suite_name,
452+
chunk.test_ids, chunk.estimated_duration, test_count: chunk.test_count
453+
)
454+
end
455+
store_chunk_metadata(chunks)
456+
push(chunks.map(&:id) + individual_tests.map(&:id))
457+
end
458+
459+
# The setup marker can expire under a slow master, so only the first
460+
# master to finish publishes.
450461
def push(tests)
451462
@total = tests.size
463+
return unless @master
452464

453-
if @master
454-
redis.multi do |transaction|
455-
transaction.lpush(key('queue'), tests) unless tests.empty?
456-
transaction.set(key('total'), @total)
457-
transaction.set(key('master-status'), 'ready')
458-
459-
transaction.expire(key('queue'), config.redis_ttl)
460-
transaction.expire(key('total'), config.redis_ttl)
461-
transaction.expire(key('master-status'), config.redis_ttl)
465+
published = redis.watch(key('master-status')) do
466+
if queue_initialized?
467+
redis.unwatch
468+
nil
469+
else
470+
redis.multi do |transaction|
471+
transaction.lpush(key('queue'), tests) unless tests.empty?
472+
transaction.set(key('total'), @total)
473+
transaction.set(key('master-status'), 'ready')
474+
475+
transaction.expire(key('queue'), config.redis_ttl)
476+
transaction.expire(key('total'), config.redis_ttl)
477+
transaction.expire(key('master-status'), config.redis_ttl)
478+
end
462479
end
463480
end
481+
@master = false unless published
464482
rescue *CONNECTION_ERRORS
465483
raise if @master
466484
end
@@ -472,7 +490,7 @@ def register
472490
def acquire_master_role?
473491
return true if @master
474492

475-
@master = redis.setnx(key('master-status'), 'setup')
493+
@master = redis.set(key('master-status'), 'setup', nx: true, px: setup_timeout_milliseconds)
476494
if @master
477495
begin
478496
redis.set(key('master-worker-id'), worker_id)
@@ -490,6 +508,11 @@ def acquire_master_role?
490508
false
491509
end
492510

511+
def setup_timeout_milliseconds
512+
# Leave a waiting worker time to take over within queue_init_timeout.
513+
[(config.queue_init_timeout.to_f * 500).ceil, 1].max
514+
end
515+
493516
def register_worker_presence
494517
register
495518
redis.expire(key('workers'), config.redis_ttl)
@@ -498,9 +521,8 @@ def register_worker_presence
498521
end
499522

500523
def store_chunk_metadata(chunks)
501-
# Batch operations to avoid exceeding Redis multi operation limits
502-
# Each chunk requires 4 commands (set, expire, sadd, hset), so batch conservatively
503-
batch_size = 5 # 5 chunks = 20 commands + 2 expires = 22 commands per batch
524+
# Four commands per chunk, plus two expiry commands: at most 22.
525+
batch_size = 5
504526

505527
chunks.each_slice(batch_size) do |chunk_batch|
506528
redis.multi do |transaction|

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_chunk_timeout_stored_in_redis_hash
3838

3939
# Verify timeout was stored in test-group-timeout hash
4040
# Timeout should be: estimated_duration (5000ms = 5s) * 1.1 buffer = 5.5s
41-
chunk_timeout = @redis.hget('build:42:test-group-timeout', 'TestA:chunk_0')
41+
chunk_timeout = @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestA:chunk_0'))
4242
refute_nil chunk_timeout
4343
assert_equal '5.5', chunk_timeout
4444
end
@@ -54,7 +54,7 @@ def test_chunk_timeout_scales_with_test_count
5454
worker.populate(small_tests)
5555
end
5656

57-
small_timeout = @redis.hget('build:42:test-group-timeout', 'SmallSuite:chunk_0')
57+
small_timeout = @redis.hget('build:42:test-group-timeout', stored_chunk_id('SmallSuite:chunk_0'))
5858
assert_equal '1.1', small_timeout # 1000ms = 1s * 1.1 buffer = 1.1s
5959

6060
@redis.flushdb
@@ -69,7 +69,7 @@ def test_chunk_timeout_scales_with_test_count
6969
worker.populate(large_tests)
7070
end
7171

72-
large_timeout = @redis.hget('build:42:test-group-timeout', 'LargeSuite:chunk_0')
72+
large_timeout = @redis.hget('build:42:test-group-timeout', stored_chunk_id('LargeSuite:chunk_0'))
7373
assert_equal '5.5', large_timeout # 5000ms = 5s * 1.1 buffer = 5.5s
7474
end
7575

@@ -92,9 +92,9 @@ def test_multiple_chunks_stored_with_different_timeouts
9292
end
9393

9494
# Verify each chunk has correct timeout
95-
assert_equal '2.2', @redis.hget('build:42:test-group-timeout', 'TestA:chunk_0') # 2000ms = 2s * 1.1 buffer = 2.2s
96-
assert_equal '3.3', @redis.hget('build:42:test-group-timeout', 'TestB:chunk_0') # 3000ms = 3s * 1.1 buffer = 3.3s
97-
assert_equal '1.1', @redis.hget('build:42:test-group-timeout', 'TestC:chunk_0') # 1000ms = 1s * 1.1 buffer = 1.1s
95+
assert_equal '2.2', @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestA:chunk_0')) # 2000ms = 2s * 1.1 buffer = 2.2s
96+
assert_equal '3.3', @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestB:chunk_0')) # 3000ms = 3s * 1.1 buffer = 3.3s
97+
assert_equal '1.1', @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestC:chunk_0')) # 1000ms = 1s * 1.1 buffer = 1.1s
9898
end
9999

100100
def test_timeout_hash_has_ttl
@@ -145,8 +145,8 @@ def test_mixed_chunks_and_tests_only_chunks_have_timeouts
145145
end
146146

147147
# Chunks should have timeouts
148-
assert_equal '2.2', @redis.hget('build:42:test-group-timeout', 'TestA:chunk_0') # 2000ms = 2s * 1.1 buffer = 2.2s
149-
assert_equal '3.3', @redis.hget('build:42:test-group-timeout', 'TestC:chunk_0') # 3000ms = 3s * 1.1 buffer = 3.3s
148+
assert_equal '2.2', @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestA:chunk_0')) # 2000ms = 2s * 1.1 buffer = 2.2s
149+
assert_equal '3.3', @redis.hget('build:42:test-group-timeout', stored_chunk_id('TestC:chunk_0')) # 3000ms = 3s * 1.1 buffer = 3.3s
150150

151151
# Individual test should not
152152
assert_nil @redis.hget('build:42:test-group-timeout', 'TestB#test_1')
@@ -235,7 +235,7 @@ def test_chunk_not_marked_lost_before_dynamic_timeout
235235

236236
# Reserve the chunk with worker1
237237
reserved_id = worker1.send(:try_to_reserve_test)
238-
assert_equal 'TestSuite:chunk_0', reserved_id
238+
assert_match /\ATestSuite:chunk_0:[0-9a-f]{16}\z/, reserved_id
239239

240240
# Wait 1 second (less than 2.5s timeout)
241241
sleep 1
@@ -296,14 +296,18 @@ def test_batching_with_many_chunks
296296

297297
# Verify all chunks have timeouts stored despite batching
298298
chunks.each do |chunk|
299-
timeout = @redis.hget('build:42:test-group-timeout', chunk.id)
299+
timeout = @redis.hget('build:42:test-group-timeout', stored_chunk_id(chunk.id))
300300
refute_nil timeout, "Chunk #{chunk.id} should have timeout stored"
301301
assert_equal '1.1', timeout # 1000ms = 1s * 1.1 buffer = 1.1s
302302
end
303303
end
304304

305305
private
306306

307+
def stored_chunk_id(original_id)
308+
@redis.smembers('build:42:chunks').find { |id| id.start_with?("#{original_id}:") }
309+
end
310+
307311
def create_mock_tests(test_ids)
308312
test_ids.map { |id| MockTest.new(id) }
309313
end

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_populate_stores_chunk_metadata_in_redis
3636
end
3737

3838
# Verify chunk metadata was stored
39-
chunk_data = @redis.get('build:42:chunk:TestA:chunk_0')
39+
chunk_data = @redis.get("build:42:chunk:#{stored_chunk_id('TestA:chunk_0')}")
4040
refute_nil chunk_data
4141

4242
parsed = JSON.parse(chunk_data)
@@ -100,10 +100,10 @@ def test_resolve_chunk
100100
end
101101

102102
# Manually store and resolve chunk
103-
resolved = @worker.send(:resolve_chunk, 'TestA:chunk_0')
103+
resolved = @worker.send(:resolve_chunk, stored_chunk_id('TestA:chunk_0'))
104104

105105
assert_instance_of CI::Queue::Redis::Worker::ResolvedChunk, resolved
106-
assert_equal 'TestA:chunk_0', resolved.id
106+
assert_match /\ATestA:chunk_0(?::[0-9a-f]{16})?\z/, resolved.id
107107
assert_equal 'TestA', resolved.suite_name
108108
assert_equal 3, resolved.tests.size
109109
assert_equal test_ids, resolved.tests.map(&:id)
@@ -120,10 +120,10 @@ def test_resolve_chunk_with_subset_of_tests
120120
@worker.populate(tests)
121121
end
122122

123-
resolved = @worker.send(:resolve_chunk, 'TestA:chunk_0')
123+
resolved = @worker.send(:resolve_chunk, stored_chunk_id('TestA:chunk_0'))
124124

125125
assert_instance_of CI::Queue::Redis::Worker::ResolvedChunk, resolved
126-
assert_equal 'TestA:chunk_0', resolved.id
126+
assert_match /\ATestA:chunk_0(?::[0-9a-f]{16})?\z/, resolved.id
127127
assert_equal 2, resolved.tests.size
128128
assert_equal ['TestA#test_1', 'TestA#test_3'], resolved.tests.map(&:id)
129129
end
@@ -140,7 +140,7 @@ def test_resolved_chunk_interface
140140

141141
resolved = CI::Queue::Redis::Worker::ResolvedChunk.new(chunk, tests)
142142

143-
assert_equal 'TestA:chunk_0', resolved.id
143+
assert_match /\ATestA:chunk_0(?::[0-9a-f]{16})?\z/, resolved.id
144144
assert_equal 'TestA', resolved.suite_name
145145
assert_equal 2, resolved.size
146146
assert resolved.chunk?
@@ -195,7 +195,7 @@ def test_populate_with_mixed_chunks_and_tests
195195

196196
# Check that both chunk and individual test IDs are in queue
197197
queue_items = @redis.lrange('build:42:queue', 0, -1)
198-
assert_includes queue_items, 'TestA:chunk_0'
198+
assert_includes queue_items, stored_chunk_id('TestA:chunk_0')
199199
assert_includes queue_items, 'TestB#test_1'
200200
end
201201

@@ -210,12 +210,12 @@ def test_chunk_metadata_has_ttl
210210
@worker.populate(tests)
211211
end
212212

213-
ttl = @redis.ttl('build:42:chunk:TestA:chunk_0')
213+
ttl = @redis.ttl("build:42:chunk:#{stored_chunk_id('TestA:chunk_0')}")
214214
assert ttl > 0, 'Chunk metadata should have TTL set'
215215
end
216216

217217
def test_populate_with_many_chunks_uses_batching
218-
# Create 20 test chunks to verify batching works (batch size is 7)
218+
# Create 20 test chunks to verify batching works (batch size is 5)
219219
tests = (1..20).map { |i| MockTest.new("TestSuite#{i}#test_1") }
220220
chunks = (1..20).map do |i|
221221
test_ids = ["TestSuite#{i}#test_1"]
@@ -228,7 +228,7 @@ def test_populate_with_many_chunks_uses_batching
228228

229229
# Verify all chunks were stored despite batching
230230
chunks.each do |chunk|
231-
chunk_data = @redis.get("build:42:chunk:#{chunk.id}")
231+
chunk_data = @redis.get("build:42:chunk:#{stored_chunk_id(chunk.id)}")
232232
refute_nil chunk_data, "Chunk #{chunk.id} should be stored"
233233

234234
parsed = JSON.parse(chunk_data)
@@ -239,7 +239,7 @@ def test_populate_with_many_chunks_uses_batching
239239
# Verify all chunk IDs are in the chunks set
240240
stored_chunks = @redis.smembers('build:42:chunks')
241241
chunks.each do |chunk|
242-
assert_includes stored_chunks, chunk.id
242+
assert_includes stored_chunks, stored_chunk_id(chunk.id)
243243
end
244244
end
245245

@@ -257,7 +257,7 @@ def test_worker_history_retry_expands_chunks_and_deduplicates_tests
257257
end
258258

259259
history_key = 'build:42:worker:1:queue'
260-
[chunk.id, tests.last.id, chunk.id, tests[1].id].each do |id|
260+
[stored_chunk_id(chunk.id), tests.last.id, stored_chunk_id(chunk.id), tests[1].id].each do |id|
261261
@redis.lpush(history_key, id)
262262
end
263263

@@ -304,6 +304,10 @@ def test_worker_history_retry_rejects_invalid_chunk_metadata
304304

305305
private
306306

307+
def stored_chunk_id(original_id)
308+
@redis.smembers('build:42:chunks').find { |id| id.start_with?("#{original_id}:") }
309+
end
310+
307311
def create_mock_tests(test_ids)
308312
test_ids.map { |id| MockTest.new(id) }
309313
end

0 commit comments

Comments
 (0)