Compare commits

..

4 Commits

Author SHA1 Message Date
Dirk Julich
1a205af41f AAP-57614 fix: remove early dispatch, rely on events_processed_hook
Dispatching save_indirect_host_entries from artifacts_handler was
fundamentally flawed: it ran before job events were written to the DB
by the callback receiver, so the task found no events to process, set
event_queries_processed=True, and blocked all future processing.

Remove the dispatch and the now-unused import.  The existing
events_processed_hook (called from both the task runner after the
final save and the callback receiver after the wrapup event) handles
dispatching at the right time — after events are in the DB.

The direct DB write of event_queries_processed=False and
installed_collections (added in the previous commit) remains: it
ensures events_processed_hook sees the correct values regardless of
which call site runs first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:22:26 +01:00
Dirk Julich
96bd35bfb4 AAP-57614 fix: also write installed_collections directly to DB
save_indirect_host_entries calls fetch_job_event_query which reads
job.installed_collections from the DB. When dispatched from
artifacts_handler, installed_collections was still only in
delay_update (not yet flushed to DB), so the task found no matching
EventQuery records and created no IndirectManagedNodeAudit entries.

Write both event_queries_processed and installed_collections directly
to the DB before dispatching, so save_indirect_host_entries has all
the data it needs immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:22:26 +01:00
Dirk Julich
21e73cb065 AAP-57614 fix: write event_queries_processed directly to DB
The previous commit dispatched save_indirect_host_entries from
artifacts_handler, but used delay_update to set event_queries_processed
to False. delay_update only queues the write for the final job status
save, so save_indirect_host_entries would read the default (True) from
the DB and bail out before processing.

Replace delay_update(event_queries_processed=False) with a direct
Job.objects.filter().update() call so the value is visible in the DB
before save_indirect_host_entries runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:22:26 +01:00
Dirk Julich
53be3d16bd AAP-57614 fix: dispatch save_indirect_host_entries from artifacts_handler
The artifacts_handler and handle_success_and_failure_notifications can
run in either order after job completion. Since event_queries_processed
defaults to True on the Job model, when the notification handler runs
first it sees True (the default) and skips dispatching
save_indirect_host_entries. When artifacts_handler runs later and sets
event_queries_processed to False, no task is dispatched to process the
EventQuery records, leaving event_queries_processed stuck at False and
no IndirectManagedNodeAudit records created.

Fix by also dispatching save_indirect_host_entries from
artifacts_handler after EventQuery records are created. The task's
select_for_update lock prevents duplicate processing if both code
paths dispatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:22:26 +01:00
3 changed files with 28 additions and 54 deletions

View File

@@ -409,12 +409,10 @@ class Command(BaseCommand):
del_child_group_pks = list(set(db_children_name_pk_map.values())) del_child_group_pks = list(set(db_children_name_pk_map.values()))
for offset in range(0, len(del_child_group_pks), self._batch_size): for offset in range(0, len(del_child_group_pks), self._batch_size):
child_group_pks = del_child_group_pks[offset : (offset + self._batch_size)] child_group_pks = del_child_group_pks[offset : (offset + self._batch_size)]
children_to_remove = list(db_children.filter(pk__in=child_group_pks)) for db_child in db_children.filter(pk__in=child_group_pks):
if children_to_remove: group_group_count += 1
group_group_count += len(children_to_remove) db_group.children.remove(db_child)
db_group.children.remove(*children_to_remove) logger.debug('Group "%s" removed from group "%s"', db_child.name, db_group.name)
for db_child in children_to_remove:
logger.debug('Group "%s" removed from group "%s"', db_child.name, db_group.name)
# FIXME: Inventory source group relationships # FIXME: Inventory source group relationships
# Delete group/host relationships not present in imported data. # Delete group/host relationships not present in imported data.
db_hosts = db_group.hosts db_hosts = db_group.hosts
@@ -443,12 +441,12 @@ class Command(BaseCommand):
del_host_pks = list(del_host_pks) del_host_pks = list(del_host_pks)
for offset in range(0, len(del_host_pks), self._batch_size): for offset in range(0, len(del_host_pks), self._batch_size):
del_pks = del_host_pks[offset : (offset + self._batch_size)] del_pks = del_host_pks[offset : (offset + self._batch_size)]
hosts_to_remove = list(db_hosts.filter(pk__in=del_pks)) for db_host in db_hosts.filter(pk__in=del_pks):
if hosts_to_remove: group_host_count += 1
group_host_count += len(hosts_to_remove) if db_host not in db_group.hosts.all():
db_group.hosts.remove(*hosts_to_remove) continue
for db_host in hosts_to_remove: db_group.hosts.remove(db_host)
logger.debug('Host "%s" removed from group "%s"', db_host.name, db_group.name) logger.debug('Host "%s" removed from group "%s"', db_host.name, db_group.name)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning( logger.warning(
'group-group and group-host deletions took %d queries for %d relationships', 'group-group and group-host deletions took %d queries for %d relationships',

View File

@@ -277,7 +277,6 @@ class RunnerCallback:
def artifacts_handler(self, artifact_dir): def artifacts_handler(self, artifact_dir):
success, query_file_contents = try_load_query_file(artifact_dir) success, query_file_contents = try_load_query_file(artifact_dir)
if success: if success:
self.delay_update(event_queries_processed=False)
collections_info = collect_queries(query_file_contents) collections_info = collect_queries(query_file_contents)
for collection, data in collections_info.items(): for collection, data in collections_info.items():
version = data['version'] version = data['version']
@@ -301,6 +300,24 @@ class RunnerCallback:
else: else:
logger.warning(f'The file {COLLECTION_FILENAME} unexpectedly did not contain ansible_version') logger.warning(f'The file {COLLECTION_FILENAME} unexpectedly did not contain ansible_version')
# Write event_queries_processed and installed_collections directly
# to the DB instead of using delay_update. delay_update defers
# writes until the final job status save, but
# events_processed_hook (called from both the task runner after
# the final save and the callback receiver after the wrapup
# event) needs event_queries_processed=False visible in the DB
# to dispatch save_indirect_host_entries. The field defaults to
# True, so without a direct write the hook would see True and
# skip the dispatch. installed_collections is also written
# directly so it is available if the callback receiver
# dispatches before the final save.
from awx.main.models import Job
db_updates = {'event_queries_processed': False}
if 'installed_collections' in query_file_contents:
db_updates['installed_collections'] = query_file_contents['installed_collections']
Job.objects.filter(id=self.instance.id).update(**db_updates)
self.artifacts_processed = True self.artifacts_processed = True

View File

@@ -305,47 +305,6 @@ class TestINIImports:
has_host_group = inventory.groups.get(name='has_a_host') has_host_group = inventory.groups.get(name='has_a_host')
assert has_host_group.hosts.count() == 1 assert has_host_group.hosts.count() == 1
@mock.patch.object(inventory_import, 'AnsibleInventoryLoader', MockLoader)
def test_overwrite_removes_stale_memberships(self, inventory):
"""When overwrite is enabled, host-group and group-group memberships
that are no longer in the imported data should be removed."""
# First import: parent_group has two children, host_group has two hosts
inventory_import.AnsibleInventoryLoader._data = {
"_meta": {"hostvars": {"host1": {}, "host2": {}}},
"all": {"children": ["ungrouped", "parent_group", "child_a", "child_b", "host_group"]},
"parent_group": {"children": ["child_a", "child_b"]},
"host_group": {"hosts": ["host1", "host2"]},
"ungrouped": {"hosts": []},
}
cmd = inventory_import.Command()
cmd.handle(inventory_id=inventory.pk, source=__file__, overwrite=True)
parent = inventory.groups.get(name='parent_group')
assert set(parent.children.values_list('name', flat=True)) == {'child_a', 'child_b'}
host_grp = inventory.groups.get(name='host_group')
assert set(host_grp.hosts.values_list('name', flat=True)) == {'host1', 'host2'}
# Second import: child_b removed from parent_group, host2 moved out of host_group
inventory_import.AnsibleInventoryLoader._data = {
"_meta": {"hostvars": {"host1": {}, "host2": {}}},
"all": {"children": ["ungrouped", "parent_group", "child_a", "child_b", "host_group"]},
"parent_group": {"children": ["child_a"]},
"host_group": {"hosts": ["host1"]},
"ungrouped": {"hosts": ["host2"]},
}
cmd = inventory_import.Command()
cmd.handle(inventory_id=inventory.pk, source=__file__, overwrite=True)
parent.refresh_from_db()
host_grp.refresh_from_db()
# child_b should be removed from parent_group
assert set(parent.children.values_list('name', flat=True)) == {'child_a'}
# host2 should be removed from host_group
assert set(host_grp.hosts.values_list('name', flat=True)) == {'host1'}
# host2 and child_b should still exist in the inventory, just not in those groups
assert inventory.hosts.filter(name='host2').exists()
assert inventory.groups.filter(name='child_b').exists()
@mock.patch.object(inventory_import, 'AnsibleInventoryLoader', MockLoader) @mock.patch.object(inventory_import, 'AnsibleInventoryLoader', MockLoader)
def test_recursive_group_error(self, inventory): def test_recursive_group_error(self, inventory):
inventory_import.AnsibleInventoryLoader._data = { inventory_import.AnsibleInventoryLoader._data = {