Skip to content

Commit 9d7fb11

Browse files
ckwartengclaude
andcommitted
ruby: Simplify LostMaster recovery to an expiring setup marker
Replace the renewable initialization lease with two smaller pieces: - The setup marker expires after half of queue_init_timeout, so a waiting worker can take over when the elected master dies before publishing. - Publication (queue, chunk metadata, total, ready status) is one WATCH-guarded transaction, so if a slow master and its replacement both finish, only the first publishes and the other joins that queue. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8b3da9d commit 9d7fb11

2 files changed

Lines changed: 102 additions & 152 deletions

File tree

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

Lines changed: 73 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'ci/queue/static'
4-
require 'concurrent/atomic/event'
54
require 'concurrent/set'
6-
require 'securerandom'
75

86
module CI
97
module Queue
@@ -19,8 +17,6 @@ class << self
1917

2018
class Worker < Base
2119
DEFAULT_SLEEP_SECONDS = 0.5
22-
InitializationLeaseLost = Class.new(StandardError)
23-
private_constant :InitializationLeaseLost
2420
attr_reader :total
2521

2622
def initialize(redis, config)
@@ -41,8 +37,8 @@ def populate(tests, random: Random.new)
4137
# All workers need an index of tests to resolve IDs
4238
@index = tests.map { |t| [t.id, t] }.to_h
4339
@total = tests.size
44-
@ordering_random = random.dup
45-
initialize_queue(tests)
40+
@random = random
41+
initialize_queue
4642

4743
register_worker_presence
4844

@@ -70,9 +66,7 @@ def idle?
7066
end
7167

7268
def poll
73-
wait_for_master(timeout: config.queue_init_timeout) do
74-
initialize_queue(index.values)
75-
end
69+
wait_for_master(timeout: config.queue_init_timeout) { initialize_queue }
7670
if master?
7771
warn "Worker #{worker_id} is the master"
7872
else
@@ -270,66 +264,6 @@ def heartbeat(test_or_id = nil)
270264

271265
attr_reader :index
272266

273-
def initialize_queue(tests)
274-
return unless acquire_master_role?
275-
276-
stop_renewal = Concurrent::Event.new
277-
renewal = Thread.new do
278-
until stop_renewal.wait(master_lease_milliseconds / 3000.0)
279-
begin
280-
with_master_lease do |transaction|
281-
transaction.pexpire(key('master-lease'), master_lease_milliseconds)
282-
end
283-
rescue InitializationLeaseLost
284-
break
285-
rescue *CONNECTION_ERRORS => e
286-
warn "Failed to renew queue initialization lease: #{e.message}"
287-
end
288-
end
289-
end
290-
291-
executables = reorder_tests(tests, random: @ordering_random.dup)
292-
chunks, individual_tests = executables.partition { |e| e.is_a?(CI::Queue::TestChunk) }
293-
store_chunk_metadata(chunks) if chunks.any?
294-
push(chunks.map(&:id) + individual_tests.map(&:id))
295-
rescue InitializationLeaseLost
296-
# Another process took over. Join its queue instead of publishing ours.
297-
@master = false
298-
ensure
299-
if renewal
300-
stop_renewal.set
301-
renewal.join
302-
release_initialization_lease
303-
end
304-
end
305-
306-
def master_lease_milliseconds
307-
# Leave time for a surviving worker to take over within its setup wait.
308-
[(config.queue_init_timeout.to_f * 500).ceil, 1].max
309-
end
310-
311-
def with_master_lease
312-
redis.watch(key('master-lease'), key('master-status')) do
313-
unless redis.get(key('master-lease')) == @master_token && master_status == 'setup'
314-
raise InitializationLeaseLost
315-
end
316-
317-
result = redis.multi { |transaction| yield transaction }
318-
raise InitializationLeaseLost unless result
319-
320-
result
321-
end
322-
end
323-
324-
def release_initialization_lease
325-
with_master_lease do |transaction|
326-
transaction.del(key('master-lease'), key('master-status'), key('master-worker-id'))
327-
end
328-
@master = false
329-
rescue InitializationLeaseLost, *CONNECTION_ERRORS
330-
# Published queues and replacement owners must remain untouched.
331-
end
332-
333267
# Runs a block while sending periodic heartbeats in a background thread.
334268
# This prevents other workers from stealing the test while it's being executed.
335269
def with_heartbeat(test_id)
@@ -503,21 +437,38 @@ def try_to_reserve_lost_test
503437
lost_test
504438
end
505439

506-
def push(tests)
440+
def initialize_queue
441+
return unless acquire_master_role?
442+
443+
executables = reorder_tests(index.values, random: @random)
444+
chunks, individual_tests = executables.partition { |e| e.is_a?(CI::Queue::TestChunk) }
445+
push(chunks.map(&:id) + individual_tests.map(&:id), chunks: chunks)
446+
end
447+
448+
# The setup marker can expire under a slow master, so only the first
449+
# master to finish publishes.
450+
def push(tests, chunks: [])
507451
@total = tests.size
452+
return unless @master
508453

509-
if @master
510-
with_master_lease do |transaction|
511-
transaction.lpush(key('queue'), tests) unless tests.empty?
512-
transaction.set(key('total'), @total)
513-
transaction.set(key('master-status'), 'ready')
514-
515-
transaction.expire(key('queue'), config.redis_ttl)
516-
transaction.expire(key('total'), config.redis_ttl)
517-
transaction.expire(key('master-status'), config.redis_ttl)
518-
transaction.del(key('master-lease'))
454+
published = redis.watch(key('master-status')) do
455+
if queue_initialized?
456+
redis.unwatch
457+
nil
458+
else
459+
redis.multi do |transaction|
460+
store_chunk_metadata(transaction, chunks)
461+
transaction.lpush(key('queue'), tests) unless tests.empty?
462+
transaction.set(key('total'), @total)
463+
transaction.set(key('master-status'), 'ready')
464+
465+
transaction.expire(key('queue'), config.redis_ttl)
466+
transaction.expire(key('total'), config.redis_ttl)
467+
transaction.expire(key('master-status'), config.redis_ttl)
468+
end
519469
end
520470
end
471+
@master = false unless published
521472
rescue *CONNECTION_ERRORS
522473
raise if @master
523474
end
@@ -527,66 +478,64 @@ def register
527478
end
528479

529480
def acquire_master_role?
530-
@master_token = SecureRandom.hex(16)
531-
@master = !!redis.watch(key('master-status'), key('master-lease')) do
532-
if %w[ready finished].include?(master_status) || redis.get(key('master-lease'))
533-
redis.unwatch
534-
false
535-
else
536-
redis.multi do |transaction|
537-
transaction.set(key('master-lease'), @master_token, px: master_lease_milliseconds)
538-
transaction.set(key('master-status'), 'setup')
539-
transaction.set(key('master-worker-id'), worker_id)
540-
transaction.expire(key('master-worker-id'), config.redis_ttl)
541-
end
481+
return true if @master
482+
483+
@master = redis.set(key('master-status'), 'setup', nx: true, px: setup_timeout_milliseconds)
484+
if @master
485+
begin
486+
redis.set(key('master-worker-id'), worker_id)
487+
redis.expire(key('master-worker-id'), config.redis_ttl)
488+
warn "Worker #{worker_id} elected as master"
489+
rescue *CONNECTION_ERRORS
490+
# If setting master-worker-id fails, we still have master status
491+
# Log but don't lose master role
492+
warn("Failed to set master-worker-id: #{$!.message}")
542493
end
543494
end
544-
warn "Worker #{worker_id} elected as master" if @master
545495
@master
546496
rescue *CONNECTION_ERRORS
547497
@master = nil
548498
false
549499
end
550500

501+
def setup_timeout_milliseconds
502+
# Leave a waiting worker time to take over within queue_init_timeout.
503+
[(config.queue_init_timeout.to_f * 500).ceil, 1].max
504+
end
505+
551506
def register_worker_presence
552507
register
553508
redis.expire(key('workers'), config.redis_ttl)
554509
rescue *CONNECTION_ERRORS
555510
raise if master?
556511
end
557512

558-
def store_chunk_metadata(chunks)
559-
# Batch operations to avoid exceeding Redis multi operation limits
560-
# Each chunk requires 4 commands (set, expire, sadd, hset), so batch conservatively
561-
batch_size = 5 # 5 chunks = 20 commands + 2 expires = 22 commands per batch
562-
563-
chunks.each_slice(batch_size) do |chunk_batch|
564-
with_master_lease do |transaction|
565-
chunk_batch.each do |chunk|
566-
# Store chunk metadata with TTL
567-
transaction.set(
568-
key('chunk', chunk.id),
569-
chunk.to_json
570-
)
571-
transaction.expire(key('chunk', chunk.id), config.redis_ttl)
572-
573-
# Track all chunks for cleanup
574-
transaction.sadd(key('chunks'), chunk.id)
575-
576-
# Store dynamic timeout for this chunk
577-
# Timeout = estimated_duration (in ms) converted to seconds + buffer
578-
# estimated_duration is in milliseconds, convert to seconds and add 10% buffer
579-
buffer_percent = 10
580-
estimated_duration_seconds = chunk.estimated_duration / 1000.0
581-
chunk_timeout = (estimated_duration_seconds * (1 + buffer_percent / 100.0)).round(2)
582-
# Format to string to avoid floating point precision issues in Redis
583-
# Use %g to remove trailing zeros
584-
transaction.hset(key('test-group-timeout'), chunk.id, format('%g', chunk_timeout))
585-
end
586-
transaction.expire(key('chunks'), config.redis_ttl)
587-
transaction.expire(key('test-group-timeout'), config.redis_ttl)
588-
end
513+
def store_chunk_metadata(transaction, chunks)
514+
return if chunks.empty?
515+
516+
chunks.each do |chunk|
517+
# Store chunk metadata with TTL
518+
transaction.set(
519+
key('chunk', chunk.id),
520+
chunk.to_json
521+
)
522+
transaction.expire(key('chunk', chunk.id), config.redis_ttl)
523+
524+
# Track all chunks for cleanup
525+
transaction.sadd(key('chunks'), chunk.id)
526+
527+
# Store dynamic timeout for this chunk
528+
# Timeout = estimated_duration (in ms) converted to seconds + buffer
529+
# estimated_duration is in milliseconds, convert to seconds and add 10% buffer
530+
buffer_percent = 10
531+
estimated_duration_seconds = chunk.estimated_duration / 1000.0
532+
chunk_timeout = (estimated_duration_seconds * (1 + buffer_percent / 100.0)).round(2)
533+
# Format to string to avoid floating point precision issues in Redis
534+
# Use %g to remove trailing zeros
535+
transaction.hset(key('test-group-timeout'), chunk.id, format('%g', chunk_timeout))
589536
end
537+
transaction.expire(key('chunks'), config.redis_ttl)
538+
transaction.expire(key('test-group-timeout'), config.redis_ttl)
590539
end
591540

592541
def chunk_id?(id)

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

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,56 +16,44 @@ def teardown
1616
end
1717

1818
def test_surviving_worker_recovers_when_initializer_exits
19-
child = fork do
20-
initializer = worker(0)
21-
initializer.define_singleton_method(:reorder_tests) { |*| exit! 143 }
22-
initializer.populate(tests)
23-
end
24-
_, status = Process.wait2(child)
25-
26-
assert_equal 143, status.exitstatus
27-
assert_equal 'setup', @redis.get(key('master-status'))
28-
assert_operator @redis.pttl(key('master-lease')), :>, 0
19+
exit_during_setup(worker_id: 0)
2920

3021
survivor = worker(1).populate(tests)
3122
refute_predicate survivor, :master?
3223
assert_equal tests.sort, poll(survivor).sort
3324
assert_predicate survivor, :master?
3425
assert_equal '1', @redis.get(key('master-worker-id'))
3526
assert_equal 'ready', @redis.get(key('master-status'))
36-
assert_nil @redis.get(key('master-lease'))
3727
end
3828

39-
def test_worker_recovers_a_setup_marker_without_a_lease
40-
@redis.set(key('master-status'), 'setup')
41-
@redis.set(key('master-worker-id'), '0')
42-
43-
replacement = worker(1).populate(tests)
29+
def test_retried_initializer_recovers_its_own_setup_marker
30+
exit_during_setup(worker_id: 0)
4431

45-
assert_predicate replacement, :master?
46-
assert_equal tests.sort, poll(replacement).sort
47-
assert_equal '1', @redis.get(key('master-worker-id'))
32+
retried = worker(0).populate(tests)
33+
assert_equal tests.sort, poll(retried).sort
34+
assert_predicate retried, :master?
35+
assert_equal 'ready', @redis.get(key('master-status'))
4836
end
4937

50-
def test_setup_exception_releases_election
38+
def test_worker_takes_over_after_setup_exception
5139
initializer = worker(0)
5240
initializer.define_singleton_method(:reorder_tests) { |*| raise 'setup failed' }
5341

5442
error = assert_raises(RuntimeError) { initializer.populate(tests) }
5543
assert_equal 'setup failed', error.message
5644

5745
replacement = worker(1).populate(tests)
58-
assert_predicate replacement, :master?
5946
assert_equal tests.sort, poll(replacement).sort
47+
assert_predicate replacement, :master?
6048
end
6149

6250
def test_late_initializer_cannot_publish_over_replacement
6351
initializer = worker(0)
6452
replacement = worker(1)
6553
redis = @redis
66-
lease_key = key('master-lease')
54+
status_key = key('master-status')
6755
initializer.define_singleton_method(:reorder_tests) do |input, **|
68-
redis.del(lease_key)
56+
redis.del(status_key)
6957
replacement.populate(input)
7058
input.reverse
7159
end
@@ -82,11 +70,11 @@ def test_late_initializer_cannot_overwrite_chunk_metadata
8270
initializer = worker(0)
8371
replacement = worker(1, strategy: :suite_bin_packing)
8472
redis = @redis
85-
lease_key = key('master-lease')
73+
status_key = key('master-status')
8674
chunk_key = key('chunk:ATest:chunk_0')
8775
published_metadata = nil
8876
initializer.define_singleton_method(:reorder_tests) do |input, **|
89-
redis.del(lease_key)
77+
redis.del(status_key)
9078
replacement.populate(input)
9179
published_metadata = redis.get(chunk_key)
9280
[CI::Queue::TestChunk.new('ATest:chunk_0', 'ATest', [input.first.id], 9999)]
@@ -99,7 +87,7 @@ def test_late_initializer_cannot_overwrite_chunk_metadata
9987
assert_equal tests.sort, poll(replacement).flat_map(&:tests).sort
10088
end
10189

102-
def test_live_initializer_renews_lease_during_slow_setup
90+
def test_waiting_worker_does_not_take_over_a_live_setup
10391
initializer = worker(0)
10492
started = Queue.new
10593
proceed = Queue.new
@@ -110,16 +98,16 @@ def test_live_initializer_renews_lease_during_slow_setup
11098
end
11199
thread = Thread.new { initializer.populate(tests) }
112100
started.pop
113-
sleep 0.4 # Longer than the initial 0.25-second lease.
114101

115102
contender = worker(1).populate(tests)
116103
refute_predicate contender, :master?
117-
assert_equal '0', @redis.get(key('master-worker-id'))
104+
assert_equal 'setup', @redis.get(key('master-status'))
118105

119106
proceed << true
120107
assert thread.join(2), 'initializer did not complete'
121108
assert_predicate initializer, :master?
122109
assert_equal tests.sort, poll(contender).sort
110+
refute_predicate contender, :master?
123111
ensure
124112
proceed << true if proceed
125113
thread&.kill
@@ -128,6 +116,19 @@ def test_live_initializer_renews_lease_during_slow_setup
128116

129117
private
130118

119+
def exit_during_setup(worker_id:)
120+
child = fork do
121+
initializer = worker(worker_id)
122+
initializer.define_singleton_method(:reorder_tests) { |*| exit! 143 }
123+
initializer.populate(tests)
124+
end
125+
_, status = Process.wait2(child)
126+
127+
assert_equal 143, status.exitstatus
128+
assert_equal 'setup', @redis.get(key('master-status'))
129+
assert_operator @redis.pttl(key('master-status')), :>, 0
130+
end
131+
131132
def tests
132133
SharedQueueAssertions::TEST_LIST.dup
133134
end

0 commit comments

Comments
 (0)