Skip to content

Commit f60769d

Browse files
committed
Lock rows for deletion & wrap async_task in transaction to preserve queue records
1 parent a9a06e7 commit f60769d

4 files changed

Lines changed: 116 additions & 45 deletions

File tree

app/services/katello/applicability/scheduler.rb

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class Scheduler
88
DRAIN_MUTEX = Mutex.new
99
DRAIN_SLEEP_SECONDS = 3
1010

11+
def self.queue
12+
Katello::ApplicableHostQueue
13+
end
14+
1115
def self.scheduler_task
1216
::ForemanTasks::Task::DynflowTask.running.for_action(Actions::Katello::Applicability::Scheduler).first
1317
end
@@ -21,32 +25,38 @@ def self.bulk_generate_tasks
2125
end
2226

2327
def self.drain_loop
24-
until (host_ids = ::Katello::ApplicableHostQueue.pop_hosts).empty?
25-
begin
26-
ForemanTasks.async_task(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: host_ids)
27-
rescue => e
28-
Rails.logger.error("Error while draining applicability queue #{e}")
29-
end
28+
catch(:done) do
29+
loop do
30+
ids = queue.pop_hosts do |host_ids|
31+
throw(:done) if host_ids.empty?
3032

31-
sleep DRAIN_SLEEP_SECONDS unless host_ids.length == ::Katello::ApplicableHostQueue.batch_size # allow some time for the queue to fill
33+
ForemanTasks.async_task(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: host_ids)
34+
end
35+
sleep DRAIN_SLEEP_SECONDS unless ids.length == queue.batch_size # allow some time for the queue to fill
36+
end
3237
end
3338
end
3439

3540
def self.trigger_drain
3641
return if DRAIN_MUTEX.locked?
3742

38-
# synchronize instead of try_lock -> consistency is more important than performance
3943
DRAIN_MUTEX.synchronize do
40-
depth = Katello::ApplicableHostQueue.queue_depth
44+
depth = queue.queue_depth
4145
return if depth == 0
4246
return if scheduler_task
4347

44-
if depth < Katello::ApplicableHostQueue.batch_size && bulk_generate_tasks.empty?
45-
host_ids = Katello::ApplicableHostQueue.pop_hosts
46-
ForemanTasks.async_task(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: host_ids)
48+
if depth < queue.batch_size && bulk_generate_tasks.empty?
49+
queue.pop_hosts do |host_ids|
50+
ForemanTasks.async_task(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: host_ids)
51+
end
4752
else
4853
# High applicability activity detected
49-
trigger_scheduler_task
54+
begin
55+
trigger_scheduler_task
56+
rescue RuntimeError => e
57+
# Scheduler already started
58+
Rails.logger.error "[applicability] #{e}"
59+
end
5060
end
5161
end
5262
end

app/services/katello/applicable_host_queue.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ def self.push_hosts(ids)
1515
ActiveSupport::Notifications.instrument("applicability_push_hosts")
1616
end
1717

18-
DELETE_QUERY = "DELETE FROM #{Katello::HostQueueElement.table_name} WHERE id IN (%s) RETURNING host_id".freeze
19-
2018
def self.pop_hosts(amount = self.batch_size)
21-
query = HostQueueElement.order(:id).select(:id).limit(amount)
22-
result = ActiveRecord::Base.connection.execute(format(DELETE_QUERY, query.to_sql))
23-
result.values.flatten
19+
HostQueueElement.transaction do
20+
elements = HostQueueElement.order(:id).select(:id, :host_id).limit(amount).lock
21+
22+
host_ids = elements.map(&:host_id)
23+
yield(host_ids) if block_given?
24+
25+
elements.delete_all
26+
host_ids
27+
end
2428
end
2529
end
2630
end

test/services/katello/applicability/scheduler_test.rb

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,111 @@
33
module Katello
44
module Applicability
55
class SchedulerTest < ActiveSupport::TestCase
6-
def queue
7-
Katello::ApplicableHostQueue
6+
let(:scheduler) { Katello::Applicability::Scheduler }
7+
let(:queue) { @queue }
8+
9+
def setup_queue(queue)
10+
@queue = queue
11+
scheduler.stubs(:queue).returns(queue)
12+
end
13+
14+
class TestQueue
15+
def pop_hosts
16+
@ids = @queue.pop
17+
yield(@ids)
18+
@ids
19+
end
20+
21+
def queue_depth
22+
@queue.flatten.length
23+
end
24+
end
25+
26+
class EmptyQueue < TestQueue
27+
def initialize
28+
@queue = []
29+
super
30+
end
31+
end
32+
33+
class SingleBatchQueue < TestQueue
34+
def initialize
35+
@queue = [[], [1]]
36+
super
37+
end
38+
39+
def batch_size
40+
1
41+
end
42+
end
43+
44+
class LowVolumeQueue < TestQueue
45+
def initialize
46+
@queue = [[], [1]]
47+
super
48+
end
49+
50+
def batch_size
51+
5
52+
end
853
end
954

1055
class DrainLoopTest < SchedulerTest
1156
test "does nothing when queue is empty" do
12-
queue.expects(:pop_hosts).returns([])
57+
setup_queue EmptyQueue.new
58+
1359
ForemanTasks.expects(:async_task).never
60+
scheduler.expects(:sleep).never
1461

15-
Katello::Applicability::Scheduler.drain_loop
62+
scheduler.drain_loop
1663
end
1764

1865
test "spawns BulkGenerate" do
19-
queue.stubs(:pop_hosts).returns([1]).then.returns([])
66+
setup_queue SingleBatchQueue.new
67+
2068
ForemanTasks.expects(:async_task).with(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: [1])
21-
Katello::Applicability::Scheduler.expects(:sleep)
69+
scheduler.expects(:sleep).never
2270

23-
Katello::Applicability::Scheduler.drain_loop
71+
scheduler.drain_loop
2472
end
2573

26-
test "doesn't sleep when queue is full" do
27-
queue.stubs(:pop_hosts).returns([1, 2, 3]).then.returns([])
28-
queue.stubs(:batch_size).returns(3)
29-
ForemanTasks.expects(:async_task)
30-
Katello::Applicability::Scheduler.expects(:sleep).never
74+
test "sleeps when queue is filling" do
75+
setup_queue LowVolumeQueue.new
76+
77+
ForemanTasks.expects(:async_task).with(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: [1])
78+
scheduler.expects(:sleep)
3179

32-
Katello::Applicability::Scheduler.drain_loop
80+
scheduler.drain_loop
3381
end
3482
end
3583

3684
class TriggerDrainTest < SchedulerTest
3785
test "spawns BulkGenerate" do
38-
queue.expects(:batch_size).returns(2)
39-
queue.expects(:queue_depth).returns(1)
40-
Katello::Applicability::Scheduler.expects(:bulk_generate_tasks).returns([])
41-
queue.expects(:pop_hosts).returns([:fake])
42-
ForemanTasks.expects(:async_task).with(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: [:fake])
86+
setup_queue LowVolumeQueue.new
87+
scheduler.expects(:bulk_generate_tasks).returns([])
88+
ForemanTasks.expects(:async_task).with(Actions::Katello::Applicability::Hosts::BulkGenerate, host_ids: [1])
4389

4490
Katello::Applicability::Scheduler.trigger_drain
4591
end
4692

4793
test "spawns scheduler task" do
48-
queue.expects(:batch_size).returns(2)
49-
queue.expects(:queue_depth).returns(3)
94+
setup_queue LowVolumeQueue.new
95+
scheduler.expects(:bulk_generate_tasks).returns([:fake])
96+
5097
ForemanTasks.expects(:async_task).with(Actions::Katello::Applicability::Scheduler)
5198

5299
Katello::Applicability::Scheduler.trigger_drain
53100
end
54101

55102
test "does nothing when queue is empty" do
56-
queue.expects(:queue_depth).returns(0)
103+
setup_queue EmptyQueue.new
57104
ForemanTasks.expects(:async_task).never
58105

59106
Katello::Applicability::Scheduler.trigger_drain
60107
end
61108

62109
test "spawns nothing when scheduler task is running" do
63-
queue.expects(:queue_depth).returns(1)
110+
setup_queue LowVolumeQueue.new
64111
Katello::Applicability::Scheduler.expects(:scheduler_task).returns(stub)
65112
ForemanTasks.expects(:async_task).never
66113

test/services/katello/applicable_host_queue_test.rb

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,30 @@ module Katello
44
class ApplicableHostQueueTest < ActiveSupport::TestCase
55
def setup
66
Setting['applicability_batch_size'] = 50
7+
8+
# Prevent any test here from triggering the scheduler & draining the queue
9+
Katello::Applicability::Scheduler.stubs(:trigger_drain)
710
end
811

912
def test_pop_nothing
1013
assert_empty ApplicableHostQueue.pop_hosts
1114
end
1215

1316
def test_pop_1_host
14-
Katello::Applicability::Scheduler.expects(:trigger_drain)
1517
ApplicableHostQueue.push_hosts([999])
1618
popped_hosts = ApplicableHostQueue.pop_hosts
1719

1820
assert_equal [999], popped_hosts.sort
1921
end
2022

2123
def test_pop_5_hosts
22-
Katello::Applicability::Scheduler.expects(:trigger_drain).times(5)
2324
5.times { |i| ApplicableHostQueue.push_hosts([i]) }
2425
popped_hosts = ApplicableHostQueue.pop_hosts
2526

2627
assert_equal [0, 1, 2, 3, 4], popped_hosts.sort
2728
end
2829

2930
def test_pop_batch_size_only
30-
Katello::Applicability::Scheduler.expects(:trigger_drain).times(5)
3131
Setting['applicability_batch_size'] = 3
3232
5.times { |i| ApplicableHostQueue.push_hosts([i]) }
3333
popped_hosts = ApplicableHostQueue.pop_hosts
@@ -36,16 +36,26 @@ def test_pop_batch_size_only
3636
end
3737

3838
def test_pop_duplicate_hosts
39-
Katello::Applicability::Scheduler.expects(:trigger_drain).times(5)
4039
5.times { |i| ApplicableHostQueue.push_hosts([i]) }
4140
5.times { |i| ApplicableHostQueue.push_hosts([i]) }
4241
popped_hosts = ApplicableHostQueue.pop_hosts
4342

4443
assert_equal [0, 1, 2, 3, 4], popped_hosts.sort
4544
end
4645

46+
def test_pop_hosts_yield_error
47+
5.times { |i| ApplicableHostQueue.push_hosts([i]) }
48+
49+
assert_raises(RuntimeError) do
50+
ApplicableHostQueue.pop_hosts do
51+
fail "Elements are not to be removed from the queue!"
52+
end
53+
end
54+
55+
assert_equal 5, HostQueueElement.count
56+
end
57+
4758
def test_queue_depth
48-
Katello::Applicability::Scheduler.expects(:trigger_drain).times(3)
4959
3.times { |i| ApplicableHostQueue.push_hosts([i]) }
5060

5161
assert_equal 3, ApplicableHostQueue.queue_depth

0 commit comments

Comments
 (0)