diff --git a/awx/main/managers.py b/awx/main/managers.py index 0500e8b29c..633be9dbdb 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -112,7 +112,7 @@ class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)): """When the parent instance of the host query set has a `kind=smart` and a `host_filter` set. Use the `host_filter` to generate the queryset for the hosts. """ - qs = super().get_queryset() + qs = super().get_queryset().defer('ansible_facts') if hasattr(self, 'instance') and hasattr(self.instance, 'host_filter') and hasattr(self.instance, 'kind'): if self.instance.kind == 'smart' and self.instance.host_filter is not None: diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 6761028dde..69250e967d 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -1331,6 +1331,7 @@ class RunJob(SourceControlMixin, BaseTask): hosts_qs = job.get_source_hosts_for_constructed_inventory() else: hosts_qs = job.inventory.hosts + hosts_qs = hosts_qs.only(*HOST_FACTS_FIELDS) finish_fact_cache( hosts_qs, artifacts_dir=os.path.join(private_data_dir, 'artifacts', str(job.id)), diff --git a/awx/main/tests/functional/dab_rbac/test_filtered_queryset_optimization.py b/awx/main/tests/functional/dab_rbac/test_filtered_queryset_optimization.py new file mode 100644 index 0000000000..966094e920 --- /dev/null +++ b/awx/main/tests/functional/dab_rbac/test_filtered_queryset_optimization.py @@ -0,0 +1,41 @@ +""" +Tests for AAP-68023: host_list_rbac performance optimization. + +The host list endpoint fetches the large ansible_facts JSON column +unnecessarily. The HostManager now defers it by default so that +list queries avoid transferring this data from PostgreSQL. +""" + +import pytest + +from awx.main.models import Host + +# --------------------------------------------------------------------------- +# AAP-68023: Verify ansible_facts column is deferred by HostManager +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestHostManagerDeferral: + """AAP-68023: The host list fetches 200+ columns unnecessarily. + + The ansible_facts JSON column is large and not used by the list + serializer. HostManager.get_queryset() must defer it so that + every query through Host.objects avoids fetching it by default. + """ + + def test_ansible_facts_deferred_by_default(self): + """ansible_facts should be in the deferred set for default Host queries.""" + qs = Host.objects.all() + deferred = qs.query.deferred_loading[0] + assert 'ansible_facts' in deferred, f'ansible_facts should be deferred by the HostManager. ' f'Deferred fields: {deferred}' + + def test_ansible_facts_accessible_when_needed(self, inventory): + """Deferred fields are still accessible — Django fetches on access.""" + host = Host.objects.create( + name='facts-host', + inventory=inventory, + ansible_facts={'os': 'linux'}, + ) + loaded = Host.objects.get(pk=host.pk) + assert loaded.ansible_facts == {'os': 'linux'} diff --git a/awx/main/tests/unit/tasks/test_jobs.py b/awx/main/tests/unit/tasks/test_jobs.py index 0f4f6d3031..0fc0fa98b6 100644 --- a/awx/main/tests/unit/tasks/test_jobs.py +++ b/awx/main/tests/unit/tasks/test_jobs.py @@ -83,11 +83,15 @@ def test_pre_post_run_hook_facts(mock_create_partition, mock_facts_settings, pri host1 = mock.MagicMock(spec=Host, id=1, name='host1', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory) host2 = mock.MagicMock(spec=Host, id=2, name='host2', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory) - # Mock hosts queryset + # Mock hosts queryset — must support .only().filter().order_by().iterator() chain hosts = [host1, host2] qs_hosts = mock.MagicMock(spec=QuerySet) qs_hosts._result_cache = hosts - qs_hosts.only.return_value = hosts + qs_hosts.__iter__ = lambda self: iter(self._result_cache) + qs_hosts.only.return_value = qs_hosts + qs_hosts.filter.return_value = qs_hosts + qs_hosts.order_by.return_value = qs_hosts + qs_hosts.iterator.side_effect = lambda: iter(qs_hosts._result_cache) qs_hosts.count.side_effect = lambda: len(qs_hosts._result_cache) inventory.hosts = qs_hosts @@ -154,9 +158,12 @@ def test_pre_post_run_hook_facts_deleted_sliced( host.inventory = mock_inventory hosts.append(host) - # Mock inventory.hosts behavior + # Mock inventory.hosts behavior — must support .only().filter().order_by().iterator() chain mock_qs_hosts = mock.MagicMock() - mock_qs_hosts.only.return_value = hosts + mock_qs_hosts.only.return_value = mock_qs_hosts + mock_qs_hosts.filter.return_value = mock_qs_hosts + mock_qs_hosts.order_by.return_value = mock_qs_hosts + mock_qs_hosts.iterator.side_effect = lambda: iter(hosts) mock_qs_hosts.count.return_value = 999 mock_inventory.hosts = mock_qs_hosts