Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Braun
d5a772d925 Merge branch 'devel' into AAP-77251 2026-07-20 16:05:53 +02:00
Peter Braun
3f5c03a4ee fix failing test 2026-07-20 16:05:34 +02:00
Peter Braun
1603d4513a update tests 2026-07-17 13:47:04 +02:00
Peter Braun
88d92d6231 fix: delete inventory hosts in batches to avoid memory exhaustion 2026-07-17 12:08:03 +02:00
2 changed files with 119 additions and 4 deletions

View File

@@ -1022,6 +1022,34 @@ def update_host_smart_inventory_memberships():
smart_inventory.update_computed_fields() smart_inventory.update_computed_fields()
def _batched_delete_inventory(inventory, batch_size=500):
"""Delete inventory hosts in batches to avoid high memory usage.
With ansible facts, loading thousands of hosts at once can use a lot of memory. To avoid
this, we delete them in batches (of 500).
Safe to retry after a crash because inventory.pending_deletion
is already set and each batch is its own transaction.
"""
from awx.main.models.inventory import Host
# first delete all hosts in batches
total_deleted = 0
while True:
pks = list(Host.objects.filter(inventory_id=inventory.id).values_list('pk', flat=True)[:batch_size])
if not pks:
break
with transaction.atomic():
deleted_count, _ = Host.objects.filter(pk__in=pks).delete()
total_deleted += deleted_count
logger.debug('Batch-deleted %d hosts from inventory %d (%d total so far)', len(pks), inventory.id, total_deleted)
# then delete the inventory itself
inv_id = inventory.id
inventory.delete()
logger.info('Batched deletion of inventory %d complete (%d hosts removed)', inv_id, total_deleted)
@task(queue=get_task_queuename, timeout=3600 * 5) @task(queue=get_task_queuename, timeout=3600 * 5)
def delete_inventory(inventory_id, user_id, retries=5): def delete_inventory(inventory_id, user_id, retries=5):
# Delete inventory as user # Delete inventory as user
@@ -1034,11 +1062,12 @@ def delete_inventory(inventory_id, user_id, retries=5):
user = None user = None
with ignore_inventory_computed_fields(), ignore_inventory_group_removal(), impersonate(user): with ignore_inventory_computed_fields(), ignore_inventory_group_removal(), impersonate(user):
try: try:
Inventory.objects.get(id=inventory_id).delete() inv = Inventory.objects.get(id=inventory_id)
_batched_delete_inventory(inv)
emit_channel_notification('inventories-status_changed', {'group_name': 'inventories', 'inventory_id': inventory_id, 'status': 'deleted'}) emit_channel_notification('inventories-status_changed', {'group_name': 'inventories', 'inventory_id': inventory_id, 'status': 'deleted'})
logger.debug('Deleted inventory {} as user {}.'.format(inventory_id, user_id)) logger.debug('Deleted inventory {} as user {}.'.format(inventory_id, user_id))
except Inventory.DoesNotExist: except Inventory.DoesNotExist:
logger.exception("Delete Inventory failed due to missing inventory: " + str(inventory_id)) logger.warning("Delete Inventory failed due to missing inventory: " + str(inventory_id))
return return
except DatabaseError: except DatabaseError:
logger.exception('Database error deleting inventory {}, but will retry.'.format(inventory_id)) logger.exception('Database error deleting inventory {}, but will retry.'.format(inventory_id))

View File

@@ -8,9 +8,18 @@ from unittest import mock
import pytest import pytest
from awx.main.tasks.system import CleanupImagesAndFiles, execution_node_health_check, inspect_established_receptor_connections, clear_setting_cache from awx.main.tasks.system import (
CleanupImagesAndFiles,
execution_node_health_check,
inspect_established_receptor_connections,
clear_setting_cache,
_batched_delete_inventory,
)
from awx.main.management.commands.dispatcherd import Command from awx.main.management.commands.dispatcherd import Command
from awx.main.models import Instance, Job, ReceptorAddress, InstanceLink from django.db import DatabaseError
from awx.main.models import Instance, Inventory, Job, Organization, ReceptorAddress, InstanceLink
from awx.main.models.inventory import Group, Host
@pytest.mark.django_db @pytest.mark.django_db
@@ -105,6 +114,83 @@ def test_folder_cleanup_multiple_running_jobs(job_folder_factory, me_inst):
assert [os.path.exists(d) for d in dirs] == [True for i in range(num_jobs)] assert [os.path.exists(d) for d in dirs] == [True for i in range(num_jobs)]
@pytest.mark.django_db
class TestBatchedDeleteInventory:
def _make_inventory_with_hosts(self, count):
from django.utils import timezone
now = timezone.now()
org = Organization.objects.create(name='test-org')
inv = Inventory.objects.create(name='test-inv', organization=org)
group = Group.objects.create(name='test-group', inventory=inv)
hosts = [Host(name=f'host-{i}', inventory=inv, created=now, modified=now) for i in range(count)]
Host.objects.bulk_create(hosts)
group.hosts.set(Host.objects.filter(inventory=inv))
return inv
def test_deletes_all_hosts_and_inventory(self):
inv = self._make_inventory_with_hosts(10)
inv_id = inv.id
_batched_delete_inventory(inv, batch_size=3)
assert not Host.objects.filter(inventory_id=inv_id).exists()
assert not Group.objects.filter(inventory_id=inv_id).exists()
assert not Inventory.objects.filter(id=inv_id).exists()
def test_no_hosts(self):
inv = self._make_inventory_with_hosts(0)
inv_id = inv.id
_batched_delete_inventory(inv)
assert not Inventory.objects.filter(id=inv_id).exists()
def test_exactly_one_batch(self):
inv = self._make_inventory_with_hosts(5)
inv_id = inv.id
_batched_delete_inventory(inv, batch_size=5)
assert not Host.objects.filter(inventory_id=inv_id).exists()
assert not Inventory.objects.filter(id=inv_id).exists()
def test_idempotent_after_partial_delete(self):
"""Simulate a crash mid-way: delete some hosts manually, then run
_batched_delete_inventory — it should finish the job cleanly."""
inv = self._make_inventory_with_hosts(10)
inv_id = inv.id
# Simulate a partial deletion (as if the task crashed after 4 hosts)
partial_pks = list(Host.objects.filter(inventory=inv).values_list('pk', flat=True)[:4])
Host.objects.filter(pk__in=partial_pks).delete()
assert Host.objects.filter(inventory_id=inv_id).count() == 6
# Re-running should delete the remaining hosts and the inventory
inv.refresh_from_db()
_batched_delete_inventory(inv, batch_size=3)
assert not Host.objects.filter(inventory_id=inv_id).exists()
assert not Inventory.objects.filter(id=inv_id).exists()
def test_delete_inventory_retries_on_database_error(self):
"""DatabaseError during deletion triggers a retry."""
from awx.main.tasks.system import delete_inventory
inv = self._make_inventory_with_hosts(3)
inv_id = inv.id
call_count = {'n': 0}
original = _batched_delete_inventory.__wrapped__ if hasattr(_batched_delete_inventory, '__wrapped__') else _batched_delete_inventory
def flaky_delete(inventory, batch_size=500):
call_count['n'] += 1
if call_count['n'] == 1:
raise DatabaseError('connection reset')
return original(inventory, batch_size=batch_size)
with mock.patch('awx.main.tasks.system._batched_delete_inventory', side_effect=flaky_delete):
with mock.patch('awx.main.tasks.system.emit_channel_notification'):
with mock.patch('awx.main.tasks.system.time.sleep'):
delete_inventory(inv_id, None, retries=2)
assert call_count['n'] == 2
assert not Inventory.objects.filter(id=inv_id).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_clear_setting_cache_log_level_branch(settings): def test_clear_setting_cache_log_level_branch(settings):
settings.LOG_AGGREGATOR_LEVEL = 'DEBUG' settings.LOG_AGGREGATOR_LEVEL = 'DEBUG'