Skip to content

Commit cd3642b

Browse files
pablomhcursoragent
andauthored
Fixes #39421 - Speed up host collection membership updates (#11781)
Replace full host collection membership reassignment with delta-based add/remove operations so updates scale with the requested hosts instead of the full collection size. Reuse the same model API from both direct and bulk endpoints, enforce join-row uniqueness, batch the join-table writes for changed hosts, and cover the new behavior with focused regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fab9abb commit cd3642b

7 files changed

Lines changed: 225 additions & 39 deletions

File tree

app/controllers/katello/api/v2/host_collections_controller.rb

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,20 @@ def update
9393
param :id, :number, :desc => N_("Id of the host collection"), :required => true
9494
param :host_ids, Array, :desc => N_("Array of host ids")
9595
def add_hosts
96-
host_ids = params[:host_ids].map(&:to_i)
96+
host_ids, found_host_ids, editable_host_ids = requested_host_membership_ids
9797

98-
@hosts = ::Host::Managed.where(id: host_ids)
99-
@editable_hosts = @hosts.authorized(:edit_hosts)
100-
101-
already_added_host_ids = @host_collection.host_ids & host_ids
102-
unfound_host_ids = host_ids - @hosts.pluck(:id)
103-
104-
@host_collection.host_ids = (@host_collection.host_ids +
105-
@editable_hosts.pluck(:id)).uniq
106-
@host_collection.save!
98+
membership_update = @host_collection.add_host_ids!(
99+
:requested_host_ids => found_host_ids,
100+
:authorized_host_ids => editable_host_ids
101+
)
102+
already_added_host_ids = membership_update[:requested_existing_host_ids]
103+
unfound_host_ids = host_ids - found_host_ids
107104

108105
messages = format_bulk_action_messages(
109106
:success => _("Successfully added %s Host(s)."),
110107
:error => _("You were not allowed to add %s"),
111-
:models => @hosts.pluck(:id) - already_added_host_ids,
112-
:authorized => @editable_hosts.pluck(:id) - already_added_host_ids
108+
:models => found_host_ids - already_added_host_ids,
109+
:authorized => membership_update[:updated_host_ids]
113110
)
114111

115112
already_added_host_ids.each do |host_id|
@@ -128,22 +125,20 @@ def add_hosts
128125
param :id, :number, :desc => N_("Id of the host collection"), :required => true
129126
param :host_ids, Array, :desc => N_("Array of host ids")
130127
def remove_hosts
131-
host_ids = params[:host_ids].map(&:to_i)
132-
133-
@hosts = ::Host::Managed.where(id: host_ids)
134-
@editable_hosts = @hosts.authorized(:edit_hosts)
135-
136-
already_removed_host_ids = @hosts.pluck(:id) - @host_collection.host_ids
137-
unfound_host_ids = host_ids - @hosts.pluck(:id)
128+
host_ids, found_host_ids, editable_host_ids = requested_host_membership_ids
138129

139-
@host_collection.host_ids -= @editable_hosts.pluck(:id)
140-
@host_collection.save!
130+
membership_update = @host_collection.remove_host_ids!(
131+
:requested_host_ids => found_host_ids,
132+
:authorized_host_ids => editable_host_ids
133+
)
134+
already_removed_host_ids = found_host_ids - membership_update[:requested_existing_host_ids]
135+
unfound_host_ids = host_ids - found_host_ids
141136

142137
messages = format_bulk_action_messages(
143138
:success => _("Successfully removed %s Host(s)."),
144139
:error => _("You were not allowed to sync %s"),
145-
:models => @hosts.pluck(:id) - already_removed_host_ids,
146-
:authorized => @editable_hosts.pluck(:id) - already_removed_host_ids
140+
:models => found_host_ids - already_removed_host_ids,
141+
:authorized => membership_update[:updated_host_ids]
147142
)
148143

149144
already_removed_host_ids.each do |host_id|
@@ -202,6 +197,13 @@ def host_collection_params_with_host_ids
202197
result
203198
end
204199

200+
def requested_host_membership_ids
201+
host_ids = Array(params[:host_ids]).map(&:to_i)
202+
@hosts = ::Host::Managed.where(id: host_ids)
203+
@editable_hosts = @hosts.authorized(:edit_hosts)
204+
[host_ids, @hosts.pluck(:id), @editable_hosts.pluck(:id)]
205+
end
206+
205207
def find_editable_host
206208
@host = resource_finder(::Host::Managed.authorized("edit_hosts"), params[:host_id]) if params[:host_id]
207209
@organization = @host.organization if @host

app/controllers/katello/api/v2/hosts_bulk_actions_controller.rb

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ class Api::V2::HostsBulkActionsController < Api::V2::ApiController
4444
def bulk_add_host_collections
4545
unless params[:host_collection_ids].blank?
4646
display_messages = []
47+
host_ids = @hosts.map(&:id)
4748

4849
@host_collections.each do |host_collection|
49-
pre_host_collection_count = host_collection.host_ids.count
50-
host_collection.host_ids = (host_collection.host_ids + @hosts.map(&:id)).uniq
51-
host_collection.save!
52-
53-
final_count = host_collection.host_ids.count - pre_host_collection_count
50+
membership_update = host_collection.add_host_ids!(
51+
:requested_host_ids => host_ids,
52+
:authorized_host_ids => host_ids
53+
)
54+
final_count = membership_update[:updated_host_ids].length
5455
msg = if final_count == 0
5556
_("All selected hosts were already members of host collection %{host_collection}.") %
5657
{:host_collection => host_collection.name }
@@ -74,12 +75,13 @@ def bulk_remove_host_collections
7475
display_messages = []
7576

7677
unless params[:host_collection_ids].blank?
78+
host_ids = @hosts.map(&:id)
7779
@host_collections.each do |host_collection|
78-
pre_host_collection_count = host_collection.host_ids.count
79-
host_collection.host_ids = (host_collection.host_ids - @hosts.map(&:id)).uniq
80-
host_collection.save!
81-
82-
final_count = pre_host_collection_count - host_collection.host_ids.count
80+
membership_update = host_collection.remove_host_ids!(
81+
:requested_host_ids => host_ids,
82+
:authorized_host_ids => host_ids
83+
)
84+
final_count = membership_update[:updated_host_ids].length
8385
display_messages << _("Removed %{count} host(s) from host collection %{host_collection}.") %
8486
{:count => final_count, :host_collection => host_collection.name }
8587
end

app/models/katello/host_collection.rb

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,14 @@ def max_hosts_check
3232
# NOTE: max_hosts_check and max_hosts_no_exceeded use size() instead of count() because
3333
# the host list exists as an array rather than a DB query when run as a validation.
3434
host_count = hosts.size
35-
if !unlimited_hosts && (host_count > 0 && (host_count.to_i > max_hosts.to_i)) && max_hosts_changed?
35+
if !unlimited_hosts && host_count > 0 && (host_count.to_i > max_hosts.to_i) && max_hosts_changed?
3636
errors.add :max_host, N_("may not be less than the number of hosts associated with the host collection.")
3737
end
3838
end
3939

4040
def max_hosts_not_exceeded
4141
if !unlimited_hosts && (hosts.size.to_i > max_hosts.to_i)
42-
errors.add :base, N_("You cannot have more than #{max_hosts} host(s) associated with host collection #{name}." %
43-
{:max_hosts => max_hosts, :name => name})
42+
errors.add :base, max_hosts_exceeded_message
4443
end
4544
end
4645

@@ -77,6 +76,36 @@ def total_hosts(cached: false)
7776
end
7877
end
7978

79+
def add_host_ids!(requested_host_ids:, authorized_host_ids:)
80+
update_host_membership(requested_host_ids, authorized_host_ids) do |existing_host_ids, allowed_host_ids|
81+
host_ids_to_add = allowed_host_ids - existing_host_ids
82+
ensure_capacity_for!(host_ids_to_add.length)
83+
create_host_collection_hosts(host_ids_to_add)
84+
85+
{
86+
:updated_host_ids => host_ids_to_add,
87+
:requested_existing_host_ids => existing_host_ids,
88+
}
89+
end
90+
end
91+
92+
def remove_host_ids!(requested_host_ids:, authorized_host_ids:)
93+
update_host_membership(requested_host_ids, authorized_host_ids) do |existing_host_ids, allowed_host_ids|
94+
host_ids_to_remove = existing_host_ids & allowed_host_ids
95+
destroy_host_collection_hosts(host_ids_to_remove)
96+
97+
{
98+
:updated_host_ids => host_ids_to_remove,
99+
:requested_existing_host_ids => existing_host_ids,
100+
}
101+
end
102+
end
103+
104+
def max_hosts_exceeded_message
105+
_("You cannot have more than %{max_hosts} host(s) associated with host collection %{host_collection}.") %
106+
{ :max_hosts => max_hosts, :host_collection => name }
107+
end
108+
80109
# Retrieve the list of accessible host collections in the organization specified, returning
81110
# them in the following arrays:
82111
# critical: those collections that have 1 or more security errata that need to be applied
@@ -115,6 +144,73 @@ def self.humanize_class_name(_name = nil)
115144
_("Host Collections")
116145
end
117146

147+
private
148+
149+
def update_host_membership(requested_host_ids, authorized_host_ids)
150+
normalized_requested_host_ids = normalize_host_ids(requested_host_ids)
151+
normalized_authorized_host_ids = normalize_host_ids(authorized_host_ids)
152+
allowed_host_ids = normalized_requested_host_ids & normalized_authorized_host_ids
153+
154+
return empty_membership_result if normalized_requested_host_ids.empty?
155+
156+
with_lock do
157+
existing_host_ids = host_collection_hosts.where(:host_id => normalized_requested_host_ids).pluck(:host_id)
158+
result = yield(existing_host_ids, allowed_host_ids)
159+
160+
clear_membership_cache if result[:updated_host_ids].any?
161+
162+
result
163+
end
164+
end
165+
166+
def empty_membership_result
167+
{
168+
:updated_host_ids => [],
169+
:requested_existing_host_ids => [],
170+
}
171+
end
172+
173+
def normalize_host_ids(host_ids)
174+
Array(host_ids).map(&:to_i).uniq
175+
end
176+
177+
def ensure_capacity_for!(additional_host_count)
178+
return if additional_host_count.zero? || unlimited_hosts
179+
180+
if host_collection_hosts.count + additional_host_count > max_hosts
181+
errors.add(:base, max_hosts_exceeded_message)
182+
fail ActiveRecord::RecordInvalid, self
183+
end
184+
end
185+
186+
def create_host_collection_hosts(host_ids)
187+
return if host_ids.empty?
188+
189+
timestamp = Time.current
190+
HostCollectionHosts.insert_all(
191+
host_ids.map do |host_id|
192+
{
193+
:host_collection_id => id,
194+
:host_id => host_id,
195+
:created_at => timestamp,
196+
:updated_at => timestamp,
197+
}
198+
end
199+
)
200+
end
201+
202+
def destroy_host_collection_hosts(host_ids)
203+
return if host_ids.empty?
204+
205+
host_collection_hosts.where(:host_id => host_ids).delete_all
206+
end
207+
208+
def clear_membership_cache
209+
association(:host_collection_hosts).reset
210+
association(:hosts).reset
211+
Rails.cache.delete("#{cache_key}/total_hosts")
212+
end
213+
118214
apipie :class, desc: "A class representing #{model_name.human} object" do
119215
name 'Host Collection'
120216
refs 'HostCollection'

app/models/katello/host_collection_hosts.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ def validate_max_hosts_not_exceeded
99
if new_record? && self.host_collection_id
1010
host_collection = HostCollection.find(self.host_collection_id)
1111
if host_collection && !host_collection.unlimited_hosts && (host_collection.hosts.size >= host_collection.max_hosts)
12-
errors.add :base,
13-
_("You cannot have more than %{max_hosts} host(s) associated with host collection '%{host_collection}'.") %
14-
{ :max_hosts => host_collection.max_hosts, :host_collection => host_collection.name }
12+
errors.add(:base, host_collection.max_hosts_exceeded_message)
1513
end
1614
end
1715
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class AddUniqueIndexToKatelloHostCollectionHosts < ActiveRecord::Migration[6.1]
2+
def up
3+
remove_duplicate_host_collection_hosts
4+
5+
add_index :katello_host_collection_hosts,
6+
[:host_collection_id, :host_id],
7+
:unique => true,
8+
:name => :index_katello_host_collection_hosts_on_collection_and_host
9+
end
10+
11+
def down
12+
remove_index :katello_host_collection_hosts,
13+
:name => :index_katello_host_collection_hosts_on_collection_and_host
14+
end
15+
16+
private
17+
18+
def remove_duplicate_host_collection_hosts
19+
execute(<<~SQL.squish)
20+
DELETE FROM katello_host_collection_hosts
21+
WHERE id IN (
22+
SELECT id
23+
FROM (
24+
SELECT id,
25+
ROW_NUMBER() OVER (PARTITION BY host_collection_id, host_id ORDER BY id) AS duplicate_rank
26+
FROM katello_host_collection_hosts
27+
) duplicate_rows
28+
WHERE duplicate_rank > 1
29+
)
30+
SQL
31+
end
32+
end

test/controllers/api/v2/hosts_bulk_actions_controller_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,21 @@ def test_add_host_collection
5353
put :bulk_add_host_collections, params: { :included => {:ids => @host_ids}, :organization_id => @org.id, :host_collection_ids => [@host_collection1.id, @host_collection2.id] }
5454

5555
assert_response :success
56+
results = JSON.parse(response.body)
5657
assert_equal 2, @host1.host_collections.count
58+
assert_includes results['displayMessages'], "Added 1 host(s) to host collection #{@host_collection1.name}."
59+
assert_includes results['displayMessages'], "Added 2 host(s) to host collection #{@host_collection2.name}."
5760
end
5861

5962
def test_remove_host_collection
6063
assert_equal 1, @host1.host_collections.count # system initially has simple_host_collection
6164
put :bulk_remove_host_collections, params: { :included => {:ids => @host_ids}, :organization_id => @org.id, :host_collection_ids => [@host_collection1.id, @host_collection2.id] }
6265

6366
assert_response :success
67+
results = JSON.parse(response.body)
6468
assert_equal 0, @host1.host_collections.count
69+
assert_includes results['displayMessages'], "Removed 1 host(s) from host collection #{@host_collection1.name}."
70+
assert_includes results['displayMessages'], "Removed 0 host(s) from host collection #{@host_collection2.name}."
6571
end
6672

6773
def test_destroy_hosts

test/models/host_collection_test.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,56 @@ def test_negative_create_with_invalid_name
147147
end
148148
end
149149

150+
def test_add_host_ids_adds_only_missing_hosts
151+
result = @simple_collection.add_host_ids!(
152+
:requested_host_ids => [@host_one.id, @host_two.id],
153+
:authorized_host_ids => [@host_one.id, @host_two.id]
154+
)
155+
156+
assert_equal [@host_one.id], result[:requested_existing_host_ids]
157+
assert_equal [@host_two.id], result[:updated_host_ids]
158+
assert_equal [@host_one.id, @host_two.id].sort, @simple_collection.reload.host_ids.sort
159+
end
160+
161+
def test_remove_host_ids_removes_only_existing_hosts
162+
result = @simple_collection.remove_host_ids!(
163+
:requested_host_ids => [@host_one.id, @host_two.id],
164+
:authorized_host_ids => [@host_one.id, @host_two.id]
165+
)
166+
167+
assert_equal [@host_one.id], result[:requested_existing_host_ids]
168+
assert_equal [@host_one.id], result[:updated_host_ids]
169+
assert_empty @simple_collection.reload.host_ids
170+
end
171+
172+
def test_add_host_ids_only_updates_requested_authorized_hosts
173+
result = @simple_collection.add_host_ids!(
174+
:requested_host_ids => [@host_two.id],
175+
:authorized_host_ids => [@host_one.id]
176+
)
177+
178+
assert_empty result[:updated_host_ids]
179+
assert_equal [@host_one.id], @simple_collection.reload.host_ids
180+
end
181+
182+
def test_add_host_ids_honors_max_hosts
183+
limited_collection = katello_host_collections(:one_host_limit_host_collection)
184+
limited_collection.add_host_ids!(
185+
:requested_host_ids => [@host_one.id],
186+
:authorized_host_ids => [@host_one.id]
187+
)
188+
189+
error = assert_raises(ActiveRecord::RecordInvalid) do
190+
limited_collection.add_host_ids!(
191+
:requested_host_ids => [@host_two.id],
192+
:authorized_host_ids => [@host_two.id]
193+
)
194+
end
195+
196+
assert_includes error.record.errors[:base], limited_collection.max_hosts_exceeded_message
197+
assert_equal [@host_one.id], limited_collection.reload.host_ids
198+
end
199+
150200
def test_audit_on_host_collection_creation
151201
new_host_collection = HostCollection.new(
152202
:name => "Test Audit Host Collection ",

0 commit comments

Comments
 (0)