Merge pull request #3933 from AlanCoding/schedule_no_op

More precise handling of schedule computed fields no-ops

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-05-23 12:39:58 +00:00
committed by GitHub
4 changed files with 185 additions and 29 deletions

View File

@@ -227,15 +227,23 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
job_kwargs['_eager_fields'] = {'launch_type': 'scheduled', 'schedule': self} job_kwargs['_eager_fields'] = {'launch_type': 'scheduled', 'schedule': self}
return job_kwargs return job_kwargs
def update_computed_fields(self): def update_computed_fields_no_save(self):
future_rs = Schedule.rrulestr(self.rrule) affects_fields = ['next_run', 'dtstart', 'dtend']
next_run_actual = future_rs.after(now()) starting_values = {}
for field_name in affects_fields:
starting_values[field_name] = getattr(self, field_name)
if next_run_actual is not None: future_rs = Schedule.rrulestr(self.rrule)
if not datetime_exists(next_run_actual):
# skip imaginary dates, like 2:30 on DST boundaries if self.enabled:
next_run_actual = future_rs.after(next_run_actual) next_run_actual = future_rs.after(now())
next_run_actual = next_run_actual.astimezone(pytz.utc) if next_run_actual is not None:
if not datetime_exists(next_run_actual):
# skip imaginary dates, like 2:30 on DST boundaries
next_run_actual = future_rs.after(next_run_actual)
next_run_actual = next_run_actual.astimezone(pytz.utc)
else:
next_run_actual = None
self.next_run = next_run_actual self.next_run = next_run_actual
try: try:
@@ -248,11 +256,38 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
self.dtend = future_rs[-1].astimezone(pytz.utc) self.dtend = future_rs[-1].astimezone(pytz.utc)
except IndexError: except IndexError:
self.dtend = None self.dtend = None
changed = any(getattr(self, field_name) != starting_values[field_name] for field_name in affects_fields)
return changed
def update_computed_fields(self):
changed = self.update_computed_fields_no_save()
if not changed:
return
emit_channel_notification('schedules-changed', dict(id=self.id, group_name='schedules')) emit_channel_notification('schedules-changed', dict(id=self.id, group_name='schedules'))
# Must save self here before calling unified_job_template computed fields
# in order for that method to be correct
# by adding modified to update fields, we avoid updating modified time
super(Schedule, self).save(update_fields=['next_run', 'dtstart', 'dtend', 'modified'])
with ignore_inventory_computed_fields(): with ignore_inventory_computed_fields():
self.unified_job_template.update_computed_fields() self.unified_job_template.update_computed_fields()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.update_computed_fields()
self.rrule = Schedule.coerce_naive_until(self.rrule) self.rrule = Schedule.coerce_naive_until(self.rrule)
changed = self.update_computed_fields_no_save()
if changed and 'update_fields' in kwargs:
for field_name in ['next_run', 'dtstart', 'dtend']:
if field_name not in kwargs['update_fields']:
kwargs['update_fields'].append(field_name)
super(Schedule, self).save(*args, **kwargs) super(Schedule, self).save(*args, **kwargs)
if changed:
with ignore_inventory_computed_fields():
self.unified_job_template.update_computed_fields()
def delete(self, *args, **kwargs):
ujt = self.unified_job_template
r = super(Schedule, self).delete(*args, **kwargs)
if ujt:
with ignore_inventory_computed_fields():
ujt.update_computed_fields()
return r

View File

@@ -253,11 +253,13 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
return self.last_job_run return self.last_job_run
def update_computed_fields(self): def update_computed_fields(self):
Schedule = self._meta.get_field('schedules').related_model related_schedules = self.schedules.filter(enabled=True, next_run__isnull=False).order_by('-next_run')
related_schedules = Schedule.objects.filter(enabled=True, unified_job_template=self, next_run__isnull=False).order_by('-next_run') new_next_schedule = related_schedules.first()
if related_schedules.exists(): if new_next_schedule:
self.next_schedule = related_schedules[0] if new_next_schedule.pk == self.next_schedule_id and new_next_schedule.next_run == self.next_job_run:
self.next_job_run = related_schedules[0].next_run return # no-op, common for infrequent schedules
self.next_schedule = new_next_schedule
self.next_job_run = new_next_schedule.next_run
self.save(update_fields=['next_schedule', 'next_job_run']) self.save(update_fields=['next_schedule', 'next_job_run'])
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -107,9 +107,6 @@ def dispatch_startup():
for sch in Schedule.objects.all(): for sch in Schedule.objects.all():
try: try:
sch.update_computed_fields() sch.update_computed_fields()
from awx.main.signals import disable_activity_stream
with disable_activity_stream():
sch.save()
except Exception: except Exception:
logger.exception("Failed to rebuild schedule {}.".format(sch)) logger.exception("Failed to rebuild schedule {}.".format(sch))
@@ -496,7 +493,7 @@ def awx_periodic_scheduler():
old_schedules = Schedule.objects.enabled().before(last_run) old_schedules = Schedule.objects.enabled().before(last_run)
for schedule in old_schedules: for schedule in old_schedules:
schedule.save() schedule.update_computed_fields()
schedules = Schedule.objects.enabled().between(last_run, run_now) schedules = Schedule.objects.enabled().between(last_run, run_now)
invalid_license = False invalid_license = False
@@ -507,7 +504,7 @@ def awx_periodic_scheduler():
for schedule in schedules: for schedule in schedules:
template = schedule.unified_job_template template = schedule.unified_job_template
schedule.save() # To update next_run timestamp. schedule.update_computed_fields() # To update next_run timestamp.
if template.cache_timeout_blocked: if template.cache_timeout_blocked:
logger.warn("Cache timeout is in the future, bypassing schedule for template %s" % str(template.id)) logger.warn("Cache timeout is in the future, bypassing schedule for template %s" % str(template.id))
continue continue

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from contextlib import contextmanager
from django.utils.timezone import now from django.utils.timezone import now
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@@ -6,7 +7,7 @@ from unittest import mock
import pytest import pytest
import pytz import pytz
from awx.main.models import JobTemplate, Schedule from awx.main.models import JobTemplate, Schedule, ActivityStream
from crum import impersonate from crum import impersonate
@@ -22,19 +23,140 @@ def job_template(inventory, project):
@pytest.mark.django_db @pytest.mark.django_db
def test_computed_fields_modified_by_retained(job_template, admin_user): class TestComputedFields:
with impersonate(admin_user):
# expired in 2015, so next_run should not be populated
dead_rrule = "DTSTART;TZID=UTC:20140520T190000 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1;BYMONTHDAY=1;UNTIL=20150530T000000Z"
continuing_rrule = "DTSTART;TZID=UTC:20140520T190000 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1;BYMONTHDAY=1"
@property
def distant_rrule(self):
# this rule should produce a next_run, but it should not overlap with test run time
this_year = now().year
return "DTSTART;TZID=UTC:{}0520T190000 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1;BYMONTHDAY=1;UNTIL={}0530T000000Z".format(
this_year + 1, this_year + 2
)
@contextmanager
def assert_no_unwanted_stuff(self, schedule, act_stream=True, sch_assert=True):
"""These changes are not wanted for any computed fields update
of schedules, so we make the assertions for all of the tests here
"""
original_sch_modified = schedule.modified
original_sch_modified_by = schedule.modified_by
original_ujt_modified = schedule.unified_job_template.modified
original_ujt_modified_by = schedule.unified_job_template.modified_by
original_AS_entries = ActivityStream.objects.count()
yield None
if sch_assert:
schedule.refresh_from_db()
assert schedule.modified == original_sch_modified
assert schedule.modified_by == original_sch_modified_by
# a related schedule update should not change JT modified time
schedule.unified_job_template.refresh_from_db()
assert schedule.unified_job_template.modified == original_ujt_modified
assert schedule.unified_job_template.modified_by == original_ujt_modified_by
if act_stream:
assert ActivityStream.objects.count() == original_AS_entries, (
ActivityStream.objects.order_by('-timestamp').first().changes
)
def test_computed_fields_modified_by_retained(self, job_template, admin_user):
with impersonate(admin_user):
s = Schedule.objects.create(
name='Some Schedule',
rrule='DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1',
unified_job_template=job_template
)
assert s.created_by == admin_user
with self.assert_no_unwanted_stuff(s):
s.update_computed_fields() # modification done by system here
s.save()
assert s.modified_by == admin_user
def test_computed_fields_no_op(self, job_template):
s = Schedule.objects.create( s = Schedule.objects.create(
name='Some Schedule', name='Some Schedule',
rrule='DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1', rrule=self.dead_rrule,
unified_job_template=job_template,
enabled=True
)
with self.assert_no_unwanted_stuff(s):
assert s.next_run is None
assert s.dtend is not None
prior_dtend = s.dtend
s.update_computed_fields()
assert s.next_run is None
assert s.dtend == prior_dtend
def test_computed_fields_time_change(self, job_template):
s = Schedule.objects.create(
name='Some Schedule',
rrule=self.continuing_rrule,
unified_job_template=job_template,
enabled=True
)
with self.assert_no_unwanted_stuff(s):
# force update of next_run, as if schedule re-calculation had not happened
# since this time
old_next_run = datetime(2009, 3, 13, tzinfo=pytz.utc)
Schedule.objects.filter(pk=s.pk).update(next_run=old_next_run)
s.next_run = old_next_run
prior_modified = s.modified
s.update_computed_fields()
assert s.next_run != old_next_run
assert s.modified == prior_modified
def test_computed_fields_turning_on(self, job_template):
s = Schedule.objects.create(
name='Some Schedule',
rrule=self.distant_rrule,
unified_job_template=job_template,
enabled=False
)
# we expect 1 activity stream entry for changing enabled field
with self.assert_no_unwanted_stuff(s, act_stream=False):
assert s.next_run is None
assert job_template.next_schedule is None
s.enabled = True
s.save(update_fields=['enabled'])
assert s.next_run is not None
assert job_template.next_schedule == s
def test_computed_fields_turning_on_via_rrule(self, job_template):
s = Schedule.objects.create(
name='Some Schedule',
rrule=self.dead_rrule,
unified_job_template=job_template unified_job_template=job_template
) )
s.refresh_from_db() with self.assert_no_unwanted_stuff(s, act_stream=False):
assert s.created_by == admin_user assert s.next_run is None
assert s.modified_by == admin_user assert job_template.next_schedule is None
s.update_computed_fields() s.rrule = self.distant_rrule
s.save() s.update_computed_fields()
assert s.modified_by == admin_user assert s.next_run is not None
assert job_template.next_schedule == s
def test_computed_fields_turning_off_by_deleting(self, job_template):
s1 = Schedule.objects.create(
name='first schedule',
rrule=self.distant_rrule,
unified_job_template=job_template
)
s2 = Schedule.objects.create(
name='second schedule',
rrule=self.distant_rrule,
unified_job_template=job_template
)
assert job_template.next_schedule in [s1, s2]
if job_template.next_schedule == s1:
expected_schedule = s2
else:
expected_schedule = s1
with self.assert_no_unwanted_stuff(expected_schedule, act_stream=False, sch_assert=False):
job_template.next_schedule.delete()
job_template.refresh_from_db()
assert job_template.next_schedule == expected_schedule
@pytest.mark.django_db @pytest.mark.django_db