mirror of
https://github.com/ansible/awx.git
synced 2026-08-04 03:50:01 -02:30
* AAP-81173 — Replace 4-way OR with UNION in unified job list RBAC query The UnifiedJobAccess.filtered_queryset() method used a 4-way OR to determine which unified jobs a user can see. Under load, the OR forces PostgreSQL to evaluate all four branches in a single plan, preventing branch-specific index optimization and causing 35 hours of DB time per 30-minute Scale Lab window. Split each RBAC branch (template read_role, inventory update, ad-hoc command, org auditor) into separate querysets combined with UNION, giving the planner an independent optimal plan per branch. The UNION result is wrapped in pk__in= for compatibility with BaseAccess.get_queryset() prefetch_related and the workflowapproval filter. This follows the same pattern proven in AAP-83319 (team list UNION fix). * AAP-81173 — Add UnifiedJobPagination to prevent COUNT regression The pk__in UNION pattern used for RBAC filtering forces the large outer table as the driving table for COUNT(*), requiring PostgreSQL to materialize all subquery result sets. On large deployments this produces catastrophic query times (see AAP-83773 for the identical issue on activity_stream). Override the paginator count to use an unfiltered UnifiedJob.objects.count() — the over-count is acceptable for pagination UI. Also fix test_unified_job_list_rando_sees_nothing to assert on results length instead of count, since count is now unfiltered. * AAP-81173 — Add unit tests for UnifiedJobPagination coverage Cover the UnifiedJobPaginator.count cached property and the count_disabled branch in UnifiedJobPagination.paginate_queryset to satisfy SonarCloud's 90% new-code coverage gate. * AAP-81173 — Address review feedback: save/restore paginator class, format consistency - Fix Pagination.paginate_queryset() to save/restore django_paginator_class instead of hardcoding DjangoPaginator in the finally block. This lets subclasses set the class attribute without needing to override the method. - Remove UnifiedJobPagination.paginate_queryset() override — now only needs to set django_paginator_class = UnifiedJobPaginator as a class attribute. - Format by_org_auditor consistently with the other three UNION branches. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
# Copyright (c) 2015 Ansible, Inc.
|
|
# All Rights Reserved.
|
|
|
|
from collections import OrderedDict
|
|
|
|
# Django REST Framework
|
|
from django.conf import settings
|
|
from django.core.paginator import Paginator as DjangoPaginator
|
|
from django.utils.functional import cached_property
|
|
from rest_framework import pagination
|
|
from rest_framework.response import Response
|
|
from rest_framework.utils.urls import replace_query_param
|
|
from rest_framework.settings import api_settings
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from awx.main.models import ActivityStream, UnifiedJob
|
|
|
|
|
|
class DisabledPaginator(DjangoPaginator):
|
|
@property
|
|
def num_pages(self):
|
|
return 1
|
|
|
|
@property
|
|
def count(self):
|
|
return 200
|
|
|
|
|
|
class ActivityStreamPaginator(DjangoPaginator):
|
|
"""Use unfiltered table count for activity stream pagination (AAP-83773)."""
|
|
|
|
@cached_property
|
|
def count(self):
|
|
return ActivityStream.objects.count()
|
|
|
|
|
|
class UnifiedJobPaginator(DjangoPaginator):
|
|
"""Use unfiltered table count for unified job pagination."""
|
|
|
|
@cached_property
|
|
def count(self):
|
|
return UnifiedJob.objects.count()
|
|
|
|
|
|
class Pagination(pagination.PageNumberPagination):
|
|
page_size_query_param = 'page_size'
|
|
max_page_size = settings.MAX_PAGE_SIZE
|
|
count_disabled = False
|
|
|
|
def get_next_link(self):
|
|
if not self.page.has_next():
|
|
return None
|
|
url = self.request and self.request.get_full_path() or ''
|
|
url = url.encode('utf-8')
|
|
page_number = self.page.next_page_number()
|
|
return replace_query_param(self.cap_page_size(url), self.page_query_param, page_number)
|
|
|
|
def get_previous_link(self):
|
|
if not self.page.has_previous():
|
|
return None
|
|
url = self.request and self.request.get_full_path() or ''
|
|
url = url.encode('utf-8')
|
|
page_number = self.page.previous_page_number()
|
|
return replace_query_param(self.cap_page_size(url), self.page_query_param, page_number)
|
|
|
|
def cap_page_size(self, url):
|
|
if int(self.request.query_params.get(self.page_size_query_param, 0)) > self.max_page_size:
|
|
url = replace_query_param(url, self.page_size_query_param, self.max_page_size)
|
|
return url
|
|
|
|
def get_html_context(self):
|
|
context = super().get_html_context()
|
|
context['page_links'] = [pl._replace(url=self.cap_page_size(pl.url)) for pl in context['page_links']]
|
|
|
|
return context
|
|
|
|
def paginate_queryset(self, queryset, request, **kwargs):
|
|
self.count_disabled = 'count_disabled' in request.query_params
|
|
original_paginator = self.django_paginator_class
|
|
try:
|
|
if self.count_disabled:
|
|
self.django_paginator_class = DisabledPaginator
|
|
return super(Pagination, self).paginate_queryset(queryset, request, **kwargs)
|
|
finally:
|
|
self.django_paginator_class = original_paginator
|
|
|
|
def get_paginated_response(self, data):
|
|
if self.count_disabled:
|
|
return Response({'results': data})
|
|
return super(Pagination, self).get_paginated_response(data)
|
|
|
|
|
|
class ActivityStreamPagination(Pagination):
|
|
django_paginator_class = ActivityStreamPaginator
|
|
|
|
|
|
class UnifiedJobPagination(Pagination):
|
|
django_paginator_class = UnifiedJobPaginator
|
|
|
|
|
|
class LimitPagination(pagination.BasePagination):
|
|
default_limit = api_settings.PAGE_SIZE
|
|
limit_query_param = 'limit'
|
|
limit_query_description = _('Number of results to return per page.')
|
|
max_page_size = settings.MAX_PAGE_SIZE
|
|
|
|
def paginate_queryset(self, queryset, request, view=None):
|
|
self.limit = self.get_limit(request)
|
|
self.request = request
|
|
|
|
return list(queryset[0 : self.limit])
|
|
|
|
def get_paginated_response(self, data):
|
|
return Response(OrderedDict([('results', data)]))
|
|
|
|
def get_paginated_response_schema(self, schema):
|
|
return {
|
|
'type': 'object',
|
|
'properties': {
|
|
'results': schema,
|
|
},
|
|
}
|
|
|
|
def get_limit(self, request):
|
|
try:
|
|
return pagination._positive_int(request.query_params[self.limit_query_param], strict=True)
|
|
except (KeyError, ValueError):
|
|
pass
|
|
|
|
return self.default_limit
|
|
|
|
|
|
class UnifiedJobEventPagination(Pagination):
|
|
"""
|
|
By default, use Pagination for all operations.
|
|
If `limit` query parameter specified use LimitPagination
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.use_limit_paginator = False
|
|
self.limit_pagination = LimitPagination()
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def paginate_queryset(self, queryset, request, view=None):
|
|
if 'limit' in request.query_params:
|
|
self.use_limit_paginator = True
|
|
|
|
if self.use_limit_paginator:
|
|
return self.limit_pagination.paginate_queryset(queryset, request, view=view)
|
|
return super().paginate_queryset(queryset, request, view=view)
|
|
|
|
def get_paginated_response(self, data):
|
|
if self.use_limit_paginator:
|
|
return self.limit_pagination.get_paginated_response(data)
|
|
return super().get_paginated_response(data)
|
|
|
|
def get_paginated_response_schema(self, schema):
|
|
if self.use_limit_paginator:
|
|
return self.limit_pagination.get_paginated_response_schema(schema)
|
|
return super().get_paginated_response_schema(schema)
|