Compare commits

..

3 Commits

Author SHA1 Message Date
Dirk Julich
2ea94a2e07 Fix formatting inconsistency in organization detail subquery annotation
Break the long .annotate() line across multiple lines to match the style used in mixin.py.
2026-06-16 15:21:18 +02:00
Dirk Julich
46f938ae82 Fix variable names which do not meet coding standards 2026-06-16 15:21:18 +02:00
Dirk Julich
33d18f5e5e Fix cartesian product in organization user/admin count queries
The organizations list and detail endpoints annotated each org with user and admin counts using two Count() calls that traverse the Role.members M2M. Django generated two LEFT JOINs on the same through table, crossing every member row with every admin row before COUNT(DISTINCT) reduced the product.

At scale (2,617 members × 46,233 admins) this produced 120M intermediate rows and 96-second query times, causing 504 timeouts.

Replace with independent Subquery expressions that each query main_rbac_roles_members separately - no cross product.

Fixes: AAP-72817
Fixes: AAP-72480
2026-06-16 15:21:18 +02:00
6 changed files with 47 additions and 82 deletions

View File

@@ -4,7 +4,8 @@
import dateutil
import logging
from django.db.models import Count
from django.db.models import Count, IntegerField, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.timezone import now
@@ -15,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status
from awx.main.constants import ACTIVE_STATES
from awx.main.models import Organization
from awx.main.models import Organization, Role
from awx.main.utils import get_object_or_400
from awx.main.models.ha import Instance, InstanceGroup, schedule_policy_task
from awx.main.models.organization import Team
@@ -178,9 +179,28 @@ class OrganizationCountsMixin(object):
db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization')
# Other members and admins of organization are always viewable
db_results['users'] = org_qs.annotate(users=Count('member_role__members', distinct=True), admins=Count('admin_role__members', distinct=True)).values(
'id', 'users', 'admins'
#
# Use independent subqueries instead of double-JOIN Count to avoid
# cartesian product.
role_members_through = Role.members.through
member_count = Subquery(
role_members_through.objects.filter(role_id=OuterRef('member_role_id'))
.values('role_id')
.annotate(cnt=Count('user_id', distinct=True))
.values('cnt'),
output_field=IntegerField(),
)
admin_count = Subquery(
role_members_through.objects.filter(role_id=OuterRef('admin_role_id'))
.values('role_id')
.annotate(cnt=Count('user_id', distinct=True))
.values('cnt'),
output_field=IntegerField(),
)
db_results['users'] = org_qs.annotate(
users=Coalesce(member_count, 0),
admins=Coalesce(admin_count, 0),
).values('id', 'users', 'admins')
count_context = {}
for org in org_id_list:

View File

@@ -5,7 +5,8 @@
import logging
# Django
from django.db.models import Count
from django.db.models import Count, IntegerField, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
@@ -77,9 +78,29 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
org_counts = {}
access_kwargs = {'accessor': self.request.user, 'role_field': 'read_role'}
# Use independent subqueries instead of double-JOIN Count to avoid
# cartesian product.
role_members_through = Role.members.through
member_count = Subquery(
role_members_through.objects.filter(role_id=OuterRef('member_role_id'))
.values('role_id')
.annotate(cnt=Count('user_id', distinct=True))
.values('cnt'),
output_field=IntegerField(),
)
admin_count = Subquery(
role_members_through.objects.filter(role_id=OuterRef('admin_role_id'))
.values('role_id')
.annotate(cnt=Count('user_id', distinct=True))
.values('cnt'),
output_field=IntegerField(),
)
direct_counts = (
Organization.objects.filter(id=org_id)
.annotate(users=Count('member_role__members', distinct=True), admins=Count('admin_role__members', distinct=True))
.annotate(
users=Coalesce(member_count, 0),
admins=Coalesce(admin_count, 0),
)
.values('users', 'admins')
)

View File

@@ -10,7 +10,6 @@ from django.db.models.functions import Lower
from ansible_base.lib.utils.db import advisory_lock
from awx.main.utils.common import memoize
from awx.main.utils.filters import SmartFilter
from awx.main.constants import RECEPTOR_PENDING
@@ -86,7 +85,6 @@ class HostLatestSummaryQuerySet(models.QuerySet):
class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)):
"""Custom manager class for Hosts model."""
@memoize(ttl=60, cache_key='host_active_count')
def active_count(self):
"""Return count of active, unique hosts for licensing.
Construction of query involves:

View File

@@ -1,19 +0,0 @@
from django.db import migrations, models
from django.db.models.functions import Lower
class Migration(migrations.Migration):
dependencies = [
('main', '0205_add_ordering_to_instancegroup_and_workflow_nodes'),
]
operations = [
migrations.AddIndex(
model_name='host',
index=models.Index(
Lower('name'),
name='main_host_name_lower_idx',
),
),
]

View File

@@ -19,7 +19,6 @@ from django.core.exceptions import ValidationError
from django.urls import resolve
from django.utils.timezone import now
from django.db.models import Q, Subquery, OuterRef
from django.db.models.functions import Lower
# REST Framework
from rest_framework.exceptions import ParseError
@@ -524,9 +523,6 @@ class Host(CommonModelNameNotUnique, RelatedJobsMixin):
app_label = 'main'
unique_together = (("name", "inventory"),) # FIXME: Add ('instance_id', 'inventory') after migration.
ordering = ('name',)
indexes = [
models.Index(Lower('name'), name='main_host_name_lower_idx'),
]
inventory = models.ForeignKey(
'Inventory',

View File

@@ -92,22 +92,6 @@ class TestInventoryScript:
@pytest.mark.django_db
class TestActiveCount:
@pytest.fixture(autouse=True)
def _bypass_active_count_cache(self):
from django.core.cache import cache
cache.delete('host_active_count')
original_set = cache.set
def skip_host_cache(key, *args, **kwargs):
if key == 'host_active_count':
return
return original_set(key, *args, **kwargs)
with mock.patch.object(cache, 'set', side_effect=skip_host_cache):
yield
cache.delete('host_active_count')
def test_host_active_count(self, organization):
inv1 = Inventory.objects.create(name='inv1', organization=organization)
inv2 = Inventory.objects.create(name='inv2', organization=organization)
@@ -157,41 +141,6 @@ class TestActiveCount:
assert Host.objects.active_count() == 2
@pytest.mark.django_db
class TestActiveCountCache:
@pytest.fixture(autouse=True)
def _clear_active_count_cache(self):
from awx.main.utils.common import memoize_delete
memoize_delete('host_active_count')
yield
memoize_delete('host_active_count')
def test_active_count_cache(self, organization):
inv = Inventory.objects.create(name='inv1', organization=organization)
inv.hosts.create(name='host1')
assert Host.objects.active_count() == 1
inv.hosts.create(name='host2')
assert Host.objects.active_count() == 1 # still cached
from awx.main.utils.common import memoize_delete
memoize_delete('host_active_count')
assert Host.objects.active_count() == 2
def test_active_count_cache_after_delete(self, organization):
inv = Inventory.objects.create(name='inv1', organization=organization)
h = inv.hosts.create(name='host1')
assert Host.objects.active_count() == 1
h.delete()
from awx.main.utils.common import memoize_delete
memoize_delete('host_active_count')
assert Host.objects.active_count() == 0
@pytest.mark.django_db
class TestSCMUpdateFeatures:
def test_source_location(self, scm_inventory_source):