Skip to content

Commit 82e2aaf

Browse files
authored
Upstream16461 - remove extra_vars from jobs and unified_jobs list endpoint (#584)
* Upstream16461 - remove extra_vars from jobs and unified_jobs list endpoint * Add a defer for the JobList to save a little DB I/O on extra_vars/artifacts Doesn't really affect the UI, as all the screens use the Unified, but the api endpoint is used by the collection, so its a minor help.
1 parent 3d36bd8 commit 82e2aaf

5 files changed

Lines changed: 212 additions & 5 deletions

File tree

awx/api/serializers.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,14 +896,37 @@ def get_launched_by(self, obj):
896896

897897

898898
class UnifiedJobListSerializer(UnifiedJobSerializer):
899+
# Heavy fields that can hold large amounts of data. They are stripped from
900+
# list responses by default to keep payloads small, and can be requested
901+
# back explicitly (and only these) via the ?include=<field>[,<field>] query
902+
# param handled by UnifiedJobIncludeMixin.
903+
OPTIONAL_INCLUDE_FIELDS = frozenset({'artifacts', 'extra_vars'})
904+
905+
_ALWAYS_STRIPPED_FIELDS = frozenset({'job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished'})
906+
899907
class Meta:
900-
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts')
908+
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished')
909+
910+
@classmethod
911+
def parse_requested_includes(cls, raw):
912+
# Single source of truth for parsing the ?include= query param, shared
913+
# with UnifiedJobIncludeMixin so the view-level defer and the
914+
# serializer-level field stripping never drift apart.
915+
requested = {name.strip() for name in (raw or '').split(',') if name.strip()}
916+
return frozenset(requested) & cls.OPTIONAL_INCLUDE_FIELDS
917+
918+
def _requested_includes(self):
919+
request = self.context.get('request')
920+
if request is None:
921+
return frozenset()
922+
return self.parse_requested_includes(request.query_params.get('include', ''))
901923

902924
def get_field_names(self, declared_fields, info):
903925
field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info)
904926
# Meta multiple inheritance and -field_name options don't seem to be
905927
# taking effect above, so remove the undesired fields here.
906-
return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts'))
928+
strip = self._ALWAYS_STRIPPED_FIELDS | (self.OPTIONAL_INCLUDE_FIELDS - self._requested_includes())
929+
return tuple(x for x in field_names if x not in strip)
907930

908931
def get_types(self):
909932
if type(self) is UnifiedJobListSerializer:

awx/api/views/__init__.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
RelatedJobsPreventDeleteMixin,
119119
UnifiedJobDeletionMixin,
120120
NoTruncateMixin,
121+
UnifiedJobIncludeMixin,
121122
)
122123
from awx.api.pagination import UnifiedJobEventPagination
123124
from awx.main.utils import set_environ
@@ -3499,10 +3500,23 @@ class SystemJobTemplateNotificationTemplatesSuccessList(SystemJobTemplateNotific
34993500
relationship = 'notification_templates_success'
35003501

35013502

3502-
class JobList(ListAPIView):
3503+
class JobList(UnifiedJobIncludeMixin, ListAPIView):
35033504
model = models.Job
35043505
serializer_class = serializers.JobListSerializer
35053506

3507+
def get_queryset(self):
3508+
qs = super().get_queryset()
3509+
# extra_vars/artifacts are large columns that JobListSerializer omits
3510+
# from list responses unless requested via ?include=. Defer the ones
3511+
# that won't be serialized so the DB doesn't read their (potentially
3512+
# TOAST-ed) values. Only safe here because Job is queried directly
3513+
# (unlike the polymorphic UnifiedJobList).
3514+
requested = self.serializer_class.parse_requested_includes(self.request.query_params.get('include', ''))
3515+
to_defer = self.serializer_class.OPTIONAL_INCLUDE_FIELDS - requested
3516+
if to_defer:
3517+
qs = qs.defer(*sorted(to_defer))
3518+
return qs
3519+
35063520

35073521
class JobDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
35083522
model = models.Job
@@ -4150,7 +4164,7 @@ class UnifiedJobTemplateList(ListAPIView):
41504164
search_fields = ('description', 'name', 'jobtemplate__playbook')
41514165

41524166

4153-
class UnifiedJobList(ListAPIView):
4167+
class UnifiedJobList(UnifiedJobIncludeMixin, ListAPIView):
41544168
model = models.UnifiedJob
41554169
serializer_class = serializers.UnifiedJobListSerializer
41564170
search_fields = ('description', 'name', 'job__playbook')

awx/api/views/mixin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,9 @@ def get_serializer_context(self):
216216
if self.request.query_params.get('no_truncate'):
217217
context.update(no_truncate=True)
218218
return context
219+
220+
221+
class UnifiedJobIncludeMixin(object):
222+
# Reserve the name 'include' so we can use it as a query param. Otherwise, the rest-filters backend
223+
# would treat it as a model field lookup.
224+
rest_filters_reserved_names = ('include',)

awx/main/tests/functional/api/test_unified_jobs_view.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,165 @@ def test_delete_ad_hoc_command_in_active_state(ad_hoc_command_factory, delete, a
145145
adhoc = ad_hoc_command_factory(initial_state=status)
146146
url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk})
147147
delete(url, None, admin, expect=403)
148+
149+
150+
@pytest.fixture
151+
def job_with_heavy_fields(job_factory):
152+
job = job_factory()
153+
job.extra_vars = '{"some_var": "some_value"}'
154+
job.artifacts = {"some_artifact": "some_value"}
155+
job.save()
156+
return job
157+
158+
159+
def _job_result(response, job_id):
160+
for row in response.data['results']:
161+
if row['id'] == job_id:
162+
return row
163+
raise AssertionError('job {} not found in {}'.format(job_id, [r['id'] for r in response.data['results']]))
164+
165+
166+
@pytest.mark.django_db
167+
def test_unified_jobs_list_excludes_heavy_fields_by_default(get, admin, job_with_heavy_fields):
168+
response = get(reverse('api:unified_job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
169+
row = _job_result(response, job_with_heavy_fields.id)
170+
assert 'artifacts' not in row
171+
assert 'extra_vars' not in row
172+
173+
174+
@pytest.mark.django_db
175+
def test_unified_jobs_list_include_artifacts(get, admin, job_with_heavy_fields):
176+
response = get(
177+
reverse('api:unified_job_list') + '?id={}&include=artifacts'.format(job_with_heavy_fields.id),
178+
admin,
179+
expect=200,
180+
)
181+
row = _job_result(response, job_with_heavy_fields.id)
182+
assert 'artifacts' in row
183+
assert 'extra_vars' not in row
184+
185+
186+
@pytest.mark.django_db
187+
def test_unified_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
188+
response = get(
189+
reverse('api:unified_job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
190+
admin,
191+
expect=200,
192+
)
193+
row = _job_result(response, job_with_heavy_fields.id)
194+
assert 'extra_vars' in row
195+
assert 'artifacts' not in row
196+
197+
198+
@pytest.mark.django_db
199+
def test_unified_jobs_list_include_both(get, admin, job_with_heavy_fields):
200+
response = get(
201+
reverse('api:unified_job_list') + '?id={}&include=artifacts,extra_vars'.format(job_with_heavy_fields.id),
202+
admin,
203+
expect=200,
204+
)
205+
row = _job_result(response, job_with_heavy_fields.id)
206+
assert 'artifacts' in row
207+
assert 'extra_vars' in row
208+
209+
210+
@pytest.mark.django_db
211+
def test_unified_jobs_list_include_tolerates_whitespace(get, admin, job_with_heavy_fields):
212+
response = get(
213+
reverse('api:unified_job_list') + '?id={}&include=%20artifacts%20,%20extra_vars%20'.format(job_with_heavy_fields.id),
214+
admin,
215+
expect=200,
216+
)
217+
row = _job_result(response, job_with_heavy_fields.id)
218+
assert 'artifacts' in row
219+
assert 'extra_vars' in row
220+
221+
222+
@pytest.mark.django_db
223+
def test_unified_jobs_list_include_ignores_unknown(get, admin, job_with_heavy_fields):
224+
response = get(
225+
reverse('api:unified_job_list') + '?id={}&include=does_not_exist'.format(job_with_heavy_fields.id),
226+
admin,
227+
expect=200,
228+
)
229+
row = _job_result(response, job_with_heavy_fields.id)
230+
assert 'artifacts' not in row
231+
assert 'extra_vars' not in row
232+
233+
234+
@pytest.mark.django_db
235+
def test_unified_jobs_list_include_does_not_honor_always_stripped(get, admin, job_with_heavy_fields):
236+
# Always-stripped fields like event_processing_finished, job_args, result_traceback
237+
# must remain stripped regardless of the ?include= param — they cannot be re-included.
238+
response = get(
239+
reverse('api:unified_job_list') + '?id={}&include=job_args,result_traceback,event_processing_finished'.format(job_with_heavy_fields.id),
240+
admin,
241+
expect=200,
242+
)
243+
row = _job_result(response, job_with_heavy_fields.id)
244+
assert 'event_processing_finished' not in row
245+
assert 'job_args' not in row
246+
assert 'result_traceback' not in row
247+
248+
249+
@pytest.mark.django_db
250+
def test_jobs_list_excludes_heavy_fields_by_default(get, admin, job_with_heavy_fields):
251+
response = get(reverse('api:job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
252+
row = _job_result(response, job_with_heavy_fields.id)
253+
assert 'artifacts' not in row
254+
assert 'extra_vars' not in row
255+
256+
257+
@pytest.mark.django_db
258+
def test_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
259+
response = get(
260+
reverse('api:job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
261+
admin,
262+
expect=200,
263+
)
264+
row = _job_result(response, job_with_heavy_fields.id)
265+
assert 'extra_vars' in row
266+
assert 'artifacts' not in row
267+
268+
269+
def _deferred_field_names(qs):
270+
# queryset.query.deferred_loading is (names, defer_flag). When defer_flag is
271+
# True the names are deferred; when False (an .only() was applied) the
272+
# deferred set is every concrete field not in names.
273+
names, defer = qs.query.deferred_loading
274+
if defer:
275+
return set(names)
276+
all_fields = {f.name for f in qs.model._meta.concrete_fields}
277+
return all_fields - set(names)
278+
279+
280+
def _job_list_queryset(user, querystring=''):
281+
from rest_framework.request import Request
282+
from rest_framework.test import APIRequestFactory
283+
284+
from awx.api.views import JobList
285+
286+
view = JobList()
287+
view.request = Request(APIRequestFactory().get('/api/v2/jobs/' + querystring))
288+
view.request.user = user
289+
return view.get_queryset()
290+
291+
292+
@pytest.mark.django_db
293+
def test_jobs_list_queryset_defers_heavy_columns_by_default(admin):
294+
deferred = _deferred_field_names(_job_list_queryset(admin))
295+
assert {'extra_vars', 'artifacts'} <= deferred
296+
297+
298+
@pytest.mark.django_db
299+
def test_jobs_list_queryset_defers_only_unrequested_heavy_columns(admin):
300+
deferred = _deferred_field_names(_job_list_queryset(admin, '?include=extra_vars'))
301+
assert 'artifacts' in deferred
302+
assert 'extra_vars' not in deferred
303+
304+
305+
@pytest.mark.django_db
306+
def test_jobs_list_queryset_defers_nothing_when_all_included(admin):
307+
deferred = _deferred_field_names(_job_list_queryset(admin, '?include=extra_vars,artifacts'))
308+
assert 'extra_vars' not in deferred
309+
assert 'artifacts' not in deferred

awx/main/tests/unit/api/serializers/test_unified_serializers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def test_unified_job_detail_exclusive_fields():
3939
For each type, assert that the only fields allowed to be exclusive to
4040
detail view are the allowed types
4141
"""
42-
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts'))
42+
allowed_detail_fields = frozenset(
43+
('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts', 'extra_vars', 'context_message')
44+
)
4345
for cls in UnifiedJob.__subclasses__():
4446
list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__))
4547
detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__))

0 commit comments

Comments
 (0)