Skip to content

Commit 209316c

Browse files
authored
Merge pull request #436 from Shopify/rh/requeue-without-linsert
Requeue near the tail in O(offset) instead of scanning the queue with LINSERT
2 parents d62ff55 + 471e2ab commit 209316c

3 files changed

Lines changed: 121 additions & 14 deletions

File tree

‎redis/requeue.lua‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,30 @@ local leases_key = KEYS[9]
1111
local max_requeues = tonumber(ARGV[1])
1212
local global_max_requeues = tonumber(ARGV[2])
1313
local entry = ARGV[3]
14-
local offset = ARGV[4]
14+
local offset = tonumber(ARGV[4]) or 0
1515
local ttl = tonumber(ARGV[5])
1616
local lease_id = ARGV[6]
1717

18+
-- Inserts `entry` behind the `offset` + 1 entries nearest the tail, the end RPOP reserves
19+
-- from: where `LINSERT BEFORE <entry at index -(offset + 1)>` would put it. LINSERT finds its
20+
-- pivot by scanning from the head, which is O(queue length) and blocks Redis on large queues;
21+
-- this only touches the tail, so it is O(offset). Queues of at most `offset` + 1 entries, and
22+
-- offsets of zero or less, push to the head instead.
23+
-- Keep in sync with reserve.lua: the Python client does not resolve `-- @include`.
24+
local function insert_with_offset(queue_key, entry, offset)
25+
if offset <= 0 or redis.call('llen', queue_key) <= offset + 1 then
26+
redis.call('lpush', queue_key, entry)
27+
return
28+
end
29+
30+
local ahead = redis.call('lrange', queue_key, -1 - offset, -1)
31+
redis.call('ltrim', queue_key, 0, -2 - offset)
32+
redis.call('rpush', queue_key, entry)
33+
for _, ahead_entry in ipairs(ahead) do
34+
redis.call('rpush', queue_key, ahead_entry)
35+
end
36+
end
37+
1838
-- Only the current lease holder can requeue a test.
1939
-- If the lease was transferred (e.g. via reserve_lost), reject the stale
2040
-- worker's requeue so the running entry stays intact for the new holder.
@@ -41,12 +61,7 @@ redis.call('hincrby', requeues_count_key, entry, 1)
4161

4262
redis.call('hdel', error_reports_key, entry)
4363

44-
local pivot = redis.call('lrange', queue_key, -1 - offset, 0 - offset)[1]
45-
if pivot then
46-
redis.call('linsert', queue_key, 'BEFORE', pivot, entry)
47-
else
48-
redis.call('lpush', queue_key, entry)
49-
end
64+
insert_with_offset(queue_key, entry, offset)
5065

5166
redis.call('hset', requeued_by_key, entry, worker_queue_key)
5267
if ttl and ttl > 0 then

‎redis/reserve.lua‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,23 @@ local current_time = ARGV[1]
1212
local defer_offset = tonumber(ARGV[2]) or 0
1313
local max_skip_attempts = 4
1414

15-
local function insert_with_offset(test)
16-
local pivot = redis.call('lrange', queue_key, -1 - defer_offset, 0 - defer_offset)[1]
17-
if pivot then
18-
redis.call('linsert', queue_key, 'BEFORE', pivot, test)
19-
else
20-
redis.call('lpush', queue_key, test)
15+
-- Inserts `entry` behind the `offset` + 1 entries nearest the tail, the end RPOP reserves
16+
-- from: where `LINSERT BEFORE <entry at index -(offset + 1)>` would put it. LINSERT finds its
17+
-- pivot by scanning from the head, which is O(queue length) and blocks Redis on large queues;
18+
-- this only touches the tail, so it is O(offset). Queues of at most `offset` + 1 entries, and
19+
-- offsets of zero or less, push to the head instead.
20+
-- Keep in sync with requeue.lua: the Python client does not resolve `-- @include`.
21+
local function insert_with_offset(queue_key, entry, offset)
22+
if offset <= 0 or redis.call('llen', queue_key) <= offset + 1 then
23+
redis.call('lpush', queue_key, entry)
24+
return
25+
end
26+
27+
local ahead = redis.call('lrange', queue_key, -1 - offset, -1)
28+
redis.call('ltrim', queue_key, 0, -2 - offset)
29+
redis.call('rpush', queue_key, entry)
30+
for _, ahead_entry in ipairs(ahead) do
31+
redis.call('rpush', queue_key, ahead_entry)
2132
end
2233
end
2334

@@ -44,7 +55,7 @@ for attempt = 1, max_skip_attempts do
4455
return claim_test(test)
4556
end
4657

47-
insert_with_offset(test)
58+
insert_with_offset(queue_key, test, defer_offset)
4859

4960
-- If this worker only finds its own requeued tests, defer once by returning nil,
5061
-- then allow pickup on a subsequent reserve attempt.

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,46 @@ def test_requeue # redefine the shared one
4343
CI::Queue::Redis.requeue_offset = previous_offset
4444
end
4545

46+
def test_requeue_runs_the_test_again_after_the_next_offset_plus_one_tests
47+
with_requeue_offset(2) do
48+
tests = numbered_tests(8)
49+
pop_order = CI::Queue.shuffle(tests, Random.new(0))
50+
queue = worker(1, tests: tests, build_id: 'requeue-offset', max_requeues: 1, requeue_tolerance: 1.0)
51+
52+
test_order = poll_failing_once(queue, pop_order.first)
53+
54+
assert_equal [pop_order[0], *pop_order[1..3], pop_order[0], *pop_order[4..]], test_order
55+
end
56+
end
57+
58+
def test_requeue_runs_the_test_last_and_keeps_the_queue_ttl_when_only_offset_plus_one_tests_remain
59+
with_requeue_offset(2) do
60+
tests = numbered_tests(4)
61+
pop_order = CI::Queue.shuffle(tests, Random.new(0))
62+
queue = worker(1, tests: tests, build_id: 'requeue-short-queue', max_requeues: 1, requeue_tolerance: 1.0)
63+
queue_ttl_after_requeue = nil
64+
65+
test_order = poll_failing_once(queue, pop_order.first) do |test|
66+
queue_ttl_after_requeue = @redis.ttl('build:requeue-short-queue:queue') if test == pop_order[1]
67+
end
68+
69+
assert_equal [*pop_order, pop_order[0]], test_order
70+
assert_operator queue_ttl_after_requeue, :>, 0
71+
end
72+
end
73+
74+
def test_requeue_with_a_zero_offset_runs_the_test_last
75+
with_requeue_offset(0) do
76+
tests = numbered_tests(4)
77+
pop_order = CI::Queue.shuffle(tests, Random.new(0))
78+
queue = worker(1, tests: tests, build_id: 'requeue-zero-offset', max_requeues: 1, requeue_tolerance: 1.0)
79+
80+
test_order = poll_failing_once(queue, pop_order.first)
81+
82+
assert_equal [*pop_order, pop_order[0]], test_order
83+
end
84+
end
85+
4686
def test_retry_queue_with_all_tests_passing
4787
poll(@queue)
4888
retry_queue = @queue.retry_queue
@@ -467,6 +507,24 @@ def test_reserve_defers_own_requeued_test_once
467507
assert_equal entry, second_try[0]
468508
end
469509

510+
def test_reserve_defers_own_requeued_test_behind_the_next_offset_plus_one_tests
511+
with_requeue_offset(2) do
512+
tests = numbered_tests(10)
513+
pop_order = CI::Queue.shuffle(tests, Random.new(0))
514+
queue = worker(1, tests: tests, build_id: 'defer-offset', max_requeues: 1, requeue_tolerance: 1.0)
515+
worker(2, tests: tests, build_id: 'defer-offset')
516+
assert_equal 2, queue.workers_count
517+
queue_after_deferral = nil
518+
519+
test_order = poll_failing_once(queue, pop_order.first) do |test|
520+
queue_after_deferral = queue.to_a if test == pop_order[4]
521+
end
522+
523+
assert_equal [*pop_order[4..6], pop_order[0], *pop_order[7..]], queue_after_deferral
524+
assert_equal [*pop_order, pop_order[0]], test_order
525+
end
526+
end
527+
470528
def test_heartbeat_only_checks_lease
471529
queue = worker(1, populate: false)
472530
entry = CI::Queue::QueueEntry.format('ATest#test_foo', '/tmp/a_test.rb')
@@ -1111,6 +1169,29 @@ def worker(id, **args)
11111169
end
11121170
end
11131171

1172+
def numbered_tests(count)
1173+
Array.new(count) { |index| SharedTestCases::TestCase.new("ATest#test_#{index}") }
1174+
end
1175+
1176+
def poll_failing_once(queue, failing_test, &block)
1177+
failed = false
1178+
passes = ->(test) do
1179+
next true if failed || test != failing_test
1180+
1181+
failed = true
1182+
false
1183+
end
1184+
poll(queue, passes, &block)
1185+
end
1186+
1187+
def with_requeue_offset(offset)
1188+
previous_offset = CI::Queue::Redis.requeue_offset
1189+
CI::Queue::Redis.requeue_offset = offset
1190+
yield
1191+
ensure
1192+
CI::Queue::Redis.requeue_offset = previous_offset
1193+
end
1194+
11141195
# A failed TLS handshake is otherwise retried for ~10 seconds.
11151196
def without_reconnect_attempts
11161197
original = ENV['CI_QUEUE_DISABLE_RECONNECT_ATTEMPTS']

0 commit comments

Comments
 (0)