diff options
-rw-r--r-- | apscheduler/triggers/base.py | 29 | ||||
-rw-r--r-- | apscheduler/triggers/cron/__init__.py | 26 | ||||
-rw-r--r-- | apscheduler/triggers/interval.py | 27 | ||||
-rw-r--r-- | docs/modules/triggers/cron.rst | 8 | ||||
-rw-r--r-- | docs/modules/triggers/interval.rst | 8 | ||||
-rw-r--r-- | tests/test_triggers.py | 185 |
6 files changed, 249 insertions, 34 deletions
diff --git a/apscheduler/triggers/base.py b/apscheduler/triggers/base.py index ba98632..ce2526a 100644 --- a/apscheduler/triggers/base.py +++ b/apscheduler/triggers/base.py @@ -1,4 +1,6 @@ from abc import ABCMeta, abstractmethod +from datetime import timedelta +import random import six @@ -17,3 +19,30 @@ class BaseTrigger(six.with_metaclass(ABCMeta)): :param datetime.datetime previous_fire_time: the previous time the trigger was fired :param datetime.datetime now: current datetime """ + + def _apply_jitter(self, next_fire_time, jitter, now): + """ + Randomize ``next_fire_time`` by adding or subtracting a random value (the jitter). If the + resulting datetime is in the past, returns the initial ``next_fire_time`` without jitter. + + ``next_fire_time - jitter <= result <= next_fire_time + jitter`` + + :param datetime.datetime|None next_fire_time: next fire time without jitter applied. If + ``None``, returns ``None``. + :param int|None jitter: maximum number of seconds to add or subtract to + ``next_fire_time``. If ``None`` or ``0``, returns ``next_fire_time`` + :param datetime.datetime now: current datetime + :return datetime.datetime|None: next fire time with a jitter. + """ + if next_fire_time is None or not jitter: + return next_fire_time + + next_fire_time_with_jitter = next_fire_time + timedelta( + seconds=random.uniform(-jitter, jitter)) + + if next_fire_time_with_jitter < now: + # Next fire time with jitter is in the past. + # Ignore jitter to avoid false misfire. + return next_fire_time + + return next_fire_time_with_jitter diff --git a/apscheduler/triggers/cron/__init__.py b/apscheduler/triggers/cron/__init__.py index eccee0c..e936190 100644 --- a/apscheduler/triggers/cron/__init__.py +++ b/apscheduler/triggers/cron/__init__.py @@ -26,6 +26,7 @@ class CronTrigger(BaseTrigger): :param datetime|str end_date: latest possible date/time to trigger on (inclusive) :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults to scheduler timezone) + :param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most. .. note:: The first weekday is always **monday**. """ @@ -42,10 +43,11 @@ class CronTrigger(BaseTrigger): 'second': BaseField } - __slots__ = 'timezone', 'start_date', 'end_date', 'fields' + __slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter' def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, - minute=None, second=None, start_date=None, end_date=None, timezone=None): + minute=None, second=None, start_date=None, end_date=None, timezone=None, + jitter=None): if timezone: self.timezone = astimezone(timezone) elif isinstance(start_date, datetime) and start_date.tzinfo: @@ -58,6 +60,8 @@ class CronTrigger(BaseTrigger): self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') + self.jitter = jitter + values = dict((key, value) for (key, value) in six.iteritems(locals()) if key in self.FIELD_NAMES and value is not None) self.fields = [] @@ -168,15 +172,18 @@ class CronTrigger(BaseTrigger): return None if fieldnum >= 0: + if self.jitter is not None: + next_date = self._apply_jitter(next_date, self.jitter, now) return next_date def __getstate__(self): return { - 'version': 1, + 'version': 2, 'timezone': self.timezone, 'start_date': self.start_date, 'end_date': self.end_date, - 'fields': self.fields + 'fields': self.fields, + 'jitter': self.jitter, } def __setstate__(self, state): @@ -184,15 +191,16 @@ class CronTrigger(BaseTrigger): if isinstance(state, tuple): state = state[1] - if state.get('version', 1) > 1: + if state.get('version', 1) > 2: raise ValueError( - 'Got serialized data for version %s of %s, but only version 1 can be handled' % - (state['version'], self.__class__.__name__)) + 'Got serialized data for version %s of %s, but only versions up to 2 can be ' + 'handled' % (state['version'], self.__class__.__name__)) self.timezone = state['timezone'] self.start_date = state['start_date'] self.end_date = state['end_date'] self.fields = state['fields'] + self.jitter = state.get('jitter') def __str__(self): options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] @@ -202,5 +210,5 @@ class CronTrigger(BaseTrigger): options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] if self.start_date: options.append("start_date='%s'" % datetime_repr(self.start_date)) - return "<%s (%s, timezone='%s')>" % ( - self.__class__.__name__, ', '.join(options), self.timezone) + return "<%s (%s, timezone='%s', jitter='%s')>" % ( + self.__class__.__name__, ', '.join(options), self.timezone, self.jitter) diff --git a/apscheduler/triggers/interval.py b/apscheduler/triggers/interval.py index fec912a..d3589a8 100644 --- a/apscheduler/triggers/interval.py +++ b/apscheduler/triggers/interval.py @@ -20,12 +20,13 @@ class IntervalTrigger(BaseTrigger): :param datetime|str start_date: starting point for the interval calculation :param datetime|str end_date: latest possible date/time to trigger on :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations + :param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most. """ - __slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length' + __slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length', 'jitter' def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, - end_date=None, timezone=None): + end_date=None, timezone=None, jitter=None): self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) self.interval_length = timedelta_seconds(self.interval) @@ -46,6 +47,8 @@ class IntervalTrigger(BaseTrigger): self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') + self.jitter = jitter + def get_next_fire_time(self, previous_fire_time, now): if previous_fire_time: next_fire_time = previous_fire_time + self.interval @@ -56,16 +59,20 @@ class IntervalTrigger(BaseTrigger): next_interval_num = int(ceil(timediff_seconds / self.interval_length)) next_fire_time = self.start_date + self.interval * next_interval_num + if self.jitter is not None: + next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now) + if not self.end_date or next_fire_time <= self.end_date: return self.timezone.normalize(next_fire_time) def __getstate__(self): return { - 'version': 1, + 'version': 2, 'timezone': self.timezone, 'start_date': self.start_date, 'end_date': self.end_date, - 'interval': self.interval + 'interval': self.interval, + 'jitter': self.jitter, } def __setstate__(self, state): @@ -73,20 +80,22 @@ class IntervalTrigger(BaseTrigger): if isinstance(state, tuple): state = state[1] - if state.get('version', 1) > 1: + if state.get('version', 1) > 2: raise ValueError( - 'Got serialized data for version %s of %s, but only version 1 can be handled' % - (state['version'], self.__class__.__name__)) + 'Got serialized data for version %s of %s, but only versions up to 2 can be ' + 'handled' % (state['version'], self.__class__.__name__)) self.timezone = state['timezone'] self.start_date = state['start_date'] self.end_date = state['end_date'] self.interval = state['interval'] self.interval_length = timedelta_seconds(self.interval) + self.jitter = state.get('jitter') def __str__(self): return 'interval[%s]' % str(self.interval) def __repr__(self): - return "<%s (interval=%r, start_date='%s', timezone='%s')>" % ( - self.__class__.__name__, self.interval, datetime_repr(self.start_date), self.timezone) + return "<%s (interval=%r, start_date='%s', timezone='%s', jitter='%s')>" % ( + self.__class__.__name__, self.interval, datetime_repr(self.start_date), self.timezone, + self.jitter) diff --git a/docs/modules/triggers/cron.rst b/docs/modules/triggers/cron.rst index 85f8f83..791156d 100644 --- a/docs/modules/triggers/cron.rst +++ b/docs/modules/triggers/cron.rst @@ -104,3 +104,11 @@ The :meth:`~apscheduler.schedulers.base.BaseScheduler.scheduled_job` decorator w @sched.scheduled_job('cron', id='my_job_id', day='last sun') def some_decorated_task(): print("I am printed at 00:00:00 on the last Sunday of every month!") + + +The ``jitter`` option enables you to add a random component to the execution time. This might be useful if you have +multiple servers and don't want them to run a job at the exact same moment or if you want to prevent jobs from running +at sharp hours:: + + # Run the `job_function` every sharp hour with an extra-delay picked randomly in a [-120,+120] seconds window. + sched.add_job(job_function, 'cron', hour='*', jitter=120) diff --git a/docs/modules/triggers/interval.rst b/docs/modules/triggers/interval.rst index 5fec5b0..f7b8ae4 100644 --- a/docs/modules/triggers/interval.rst +++ b/docs/modules/triggers/interval.rst @@ -59,3 +59,11 @@ The :meth:`~apscheduler.schedulers.base.BaseScheduler.scheduled_job` decorator w @sched.scheduled_job('interval', id='my_job_id', hours=2) def job_function(): print("Hello World") + + +The ``jitter`` option enables you to add a random component to the execution time. This might be useful if you have +multiple servers and don't want them to run a job at the exact same moment or if you want to prevent multiple jobs +with similar options from always running concurrently:: + + # Run the `job_function` every hour with an extra-delay picked randomly in a [-120,+120] seconds window. + sched.add_job(job_function, 'interval', hours=1, jitter=120) diff --git a/tests/test_triggers.py b/tests/test_triggers.py index c22136e..d854800 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -1,9 +1,11 @@ import pickle +import random from datetime import datetime, timedelta, date import pytest import pytz +from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.interval import IntervalTrigger @@ -14,11 +16,94 @@ except ImportError: from mock import Mock +class _DummyTriggerWithJitter(BaseTrigger): + def __init__(self, dt, jitter): + self.dt = dt + self.jitter = jitter + + def get_next_fire_time(self, previous_fire_time, now): + return self._apply_jitter(self.dt, self.jitter, now) + + +class TestJitter(object): + def test_jitter_disabled(self): + dt = datetime(2017, 5, 25, 14, 49, 50) + trigger = _DummyTriggerWithJitter(dt, None) + + now = datetime(2017, 5, 25, 13, 40, 44) + assert trigger.get_next_fire_time(None, now) == dt + + def test_jitter_with_none_next_fire_time(self): + trigger = _DummyTriggerWithJitter(None, 5) + now = datetime(2017, 5, 25, 13, 40, 44) + assert trigger.get_next_fire_time(None, now) is None + + def test_jitter_positive(self, monkeypatch): + monkeypatch.setattr(random, 'uniform', lambda a, b: 30.) + + now = datetime(2017, 5, 25, 13, 40, 44) + dt = datetime(2017, 5, 25, 14, 49, 50) + expected_dt = datetime(2017, 5, 25, 14, 50, 20) + + trigger = _DummyTriggerWithJitter(dt, 60) + assert trigger.get_next_fire_time(None, now) == expected_dt + + def test_jitter_in_past_but_initial_date_in_future(self, monkeypatch): + monkeypatch.setattr(random, 'uniform', lambda a, b: -30.) + + now = datetime(2017, 5, 25, 13, 40, 44) + dt = datetime(2017, 5, 25, 13, 40, 47) + expected_dt = dt + + trigger = _DummyTriggerWithJitter(dt, 60) + assert trigger.get_next_fire_time(None, now) == expected_dt + + def test_jitter_in_future_but_initial_date_in_past(self, monkeypatch): + monkeypatch.setattr(random, 'uniform', lambda a, b: 30.) + + now = datetime(2017, 5, 25, 13, 40, 44) + dt = datetime(2017, 5, 25, 13, 40, 30) + expected_dt = datetime(2017, 5, 25, 13, 41, 0) + + trigger = _DummyTriggerWithJitter(dt, 60) + assert trigger.get_next_fire_time(None, now) == expected_dt + + def test_jitter_misfire(self, monkeypatch): + monkeypatch.setattr(random, 'uniform', lambda a, b: -30.) + + now = datetime(2017, 5, 25, 13, 40, 44) + dt = datetime(2017, 5, 25, 13, 40, 40) + expected_dt = dt + + trigger = _DummyTriggerWithJitter(dt, 60) + assert trigger.get_next_fire_time(None, now) == expected_dt + + def test_jitter_is_now(self, monkeypatch): + monkeypatch.setattr(random, 'uniform', lambda a, b: 4.) + + now = datetime(2017, 5, 25, 13, 40, 44) + dt = datetime(2017, 5, 25, 13, 40, 40) + expected_dt = now + + trigger = _DummyTriggerWithJitter(dt, 60) + assert trigger.get_next_fire_time(None, now) == expected_dt + + def test_jitter(self): + now = datetime(2017, 5, 25, 13, 36, 44) + dt = datetime(2017, 5, 25, 13, 40, 45) + min_expected_dt = datetime(2017, 5, 25, 13, 40, 40) + max_expected_dt = datetime(2017, 5, 25, 13, 40, 50) + + trigger = _DummyTriggerWithJitter(dt, 5) + for _ in range(0, 100): + assert min_expected_dt <= trigger.get_next_fire_time(None, now) <= max_expected_dt + + class TestCronTrigger(object): def test_cron_trigger_1(self, timezone): trigger = CronTrigger(year='2009/2', month='1/3', day='5-13', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009/2', month='1/3', day='5-13', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009/2', month='1/3', day='5-13']" start_date = timezone.localize(datetime(2008, 12, 1)) correct_next_date = timezone.localize(datetime(2009, 1, 5)) @@ -33,7 +118,7 @@ class TestCronTrigger(object): def test_cron_trigger_3(self, timezone): trigger = CronTrigger(year='2009', month='2', hour='8-10', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='2', hour='8-10', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 2, 1, 8)) assert trigger.get_next_fire_time(None, start_date) == correct_next_date @@ -41,7 +126,7 @@ class TestCronTrigger(object): def test_cron_trigger_4(self, timezone): trigger = CronTrigger(year='2012', month='2', day='last', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2012', month='2', day='last', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") start_date = timezone.localize(datetime(2012, 2, 1)) correct_next_date = timezone.localize(datetime(2012, 2, 29)) assert trigger.get_next_fire_time(None, start_date) == correct_next_date @@ -55,11 +140,12 @@ class TestCronTrigger(object): def test_cron_zero_value(self, timezone): trigger = CronTrigger(year=2009, month=2, hour=0, timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='2', hour='0', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") def test_cron_year_list(self, timezone): trigger = CronTrigger(year='2009,2008', timezone=timezone) - assert repr(trigger) == "<CronTrigger (year='2009,2008', timezone='Europe/Berlin')>" + assert repr(trigger) == ("<CronTrigger (year='2009,2008', timezone='Europe/Berlin', " + "jitter='None')>") assert str(trigger) == "cron[year='2009,2008']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 1, 1)) @@ -70,7 +156,7 @@ class TestCronTrigger(object): start_date='2009-02-03 11:00:00', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='2', hour='8-10', " "start_date='2009-02-03 11:00:00 CET', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', month='2', hour='8-10']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 2, 4, 8)) @@ -101,7 +187,7 @@ class TestCronTrigger(object): def test_cron_weekday_overlap(self, timezone): trigger = CronTrigger(year=2009, month=1, day='6-10', day_of_week='2-4', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='1', day='6-10', " - "day_of_week='2-4', timezone='Europe/Berlin')>") + "day_of_week='2-4', timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', month='1', day='6-10', day_of_week='2-4']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 1, 7)) @@ -110,7 +196,7 @@ class TestCronTrigger(object): def test_cron_weekday_nomatch(self, timezone): trigger = CronTrigger(year=2009, month=1, day='6-10', day_of_week='0,6', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='1', day='6-10', " - "day_of_week='0,6', timezone='Europe/Berlin')>") + "day_of_week='0,6', timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', month='1', day='6-10', day_of_week='0,6']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = None @@ -119,7 +205,7 @@ class TestCronTrigger(object): def test_cron_weekday_positional(self, timezone): trigger = CronTrigger(year=2009, month=1, day='4th wed', timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='1', day='4th wed', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', month='1', day='4th wed']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 1, 28)) @@ -128,7 +214,7 @@ class TestCronTrigger(object): def test_week_1(self, timezone): trigger = CronTrigger(year=2009, month=2, week=8, timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', month='2', week='8', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', month='2', week='8']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 2, 16)) @@ -137,7 +223,7 @@ class TestCronTrigger(object): def test_week_2(self, timezone): trigger = CronTrigger(year=2009, week=15, day_of_week=2, timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', week='15', day_of_week='2', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', week='15', day_of_week='2']" start_date = timezone.localize(datetime(2009, 1, 1)) correct_next_date = timezone.localize(datetime(2009, 4, 8)) @@ -146,7 +232,8 @@ class TestCronTrigger(object): def test_cron_extra_coverage(self, timezone): # This test has no value other than patching holes in test coverage trigger = CronTrigger(day='6,8', timezone=timezone) - assert repr(trigger) == "<CronTrigger (day='6,8', timezone='Europe/Berlin')>" + assert repr(trigger) == ("<CronTrigger (day='6,8', timezone='Europe/Berlin', " + "jitter='None')>") assert str(trigger) == "cron[day='6,8']" start_date = timezone.localize(datetime(2009, 12, 31)) correct_next_date = timezone.localize(datetime(2010, 1, 6)) @@ -162,7 +249,8 @@ class TestCronTrigger(object): """ trigger = CronTrigger(hour='5-6', timezone=timezone) - assert repr(trigger) == "<CronTrigger (hour='5-6', timezone='Europe/Berlin')>" + assert repr(trigger) == ("<CronTrigger (hour='5-6', timezone='Europe/Berlin', " + "jitter='None')>") assert str(trigger) == "cron[hour='5-6']" start_date = timezone.localize(datetime(2009, 9, 25, 7)) correct_next_date = timezone.localize(datetime(2009, 9, 26, 5)) @@ -200,7 +288,7 @@ class TestCronTrigger(object): alter_tz = pytz.FixedOffset(-600) trigger = CronTrigger(year=2009, week=15, day_of_week=2, timezone=timezone) assert repr(trigger) == ("<CronTrigger (year='2009', week='15', day_of_week='2', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") assert str(trigger) == "cron[year='2009', week='15', day_of_week='2']" start_date = alter_tz.localize(datetime(2008, 12, 31, 22)) correct_next_date = timezone.localize(datetime(2009, 4, 8)) @@ -249,6 +337,43 @@ class TestCronTrigger(object): for attr in CronTrigger.__slots__: assert getattr(trigger2, attr) == getattr(trigger, attr) + def test_jitter_produces_differrent_valid_results(self, timezone): + trigger = CronTrigger(minute='*', jitter=5) + now = timezone.localize(datetime(2017, 11, 12, 6, 55, 30)) + + results = set() + for _ in range(0, 100): + next_fire_time = trigger.get_next_fire_time(None, now) + results.add(next_fire_time) + assert timedelta(seconds=25) <= (next_fire_time - now) <= timedelta(seconds=35) + assert 1 < len(results) + + def test_jitter_with_timezone(self, timezone): + est = pytz.FixedOffset(-300) + cst = pytz.FixedOffset(-360) + trigger = CronTrigger(hour=11, minute='*/5', timezone=est, jitter=5) + start_date = cst.localize(datetime(2009, 9, 26, 10, 16)) + correct_next_date = est.localize(datetime(2009, 9, 26, 11, 20)) + for _ in range(0, 100): + assert abs(trigger.get_next_fire_time(None, start_date) - + correct_next_date) <= timedelta(seconds=5) + + @pytest.mark.parametrize('trigger_args, start_date, start_date_dst, correct_next_date', [ + ({'hour': 8}, datetime(2013, 3, 9, 12), False, datetime(2013, 3, 10, 8)), + ({'hour': 8}, datetime(2013, 11, 2, 12), True, datetime(2013, 11, 3, 8)), + ({'minute': '*/30'}, datetime(2013, 3, 10, 1, 35), False, datetime(2013, 3, 10, 3)), + ({'minute': '*/30'}, datetime(2013, 11, 3, 1, 35), True, datetime(2013, 11, 3, 1)) + ], ids=['absolute_spring', 'absolute_autumn', 'interval_spring', 'interval_autumn']) + def test_jitter_dst_change(self, trigger_args, start_date, start_date_dst, correct_next_date): + timezone = pytz.timezone('US/Eastern') + trigger = CronTrigger(timezone=timezone, jitter=5, **trigger_args) + start_date = timezone.localize(start_date, is_dst=start_date_dst) + correct_next_date = timezone.localize(correct_next_date, is_dst=not start_date_dst) + + for _ in range(0, 100): + next_fire_time = trigger.get_next_fire_time(None, start_date) + assert abs(next_fire_time - correct_next_date) <= timedelta(seconds=5) + class TestDateTrigger(object): @pytest.mark.parametrize('run_date,alter_tz,previous,now,expected', [ @@ -375,7 +500,7 @@ class TestIntervalTrigger(object): def test_repr(self, trigger): assert repr(trigger) == ("<IntervalTrigger (interval=datetime.timedelta(0, 1), " "start_date='2009-08-04 00:00:02 CEST', " - "timezone='Europe/Berlin')>") + "timezone='Europe/Berlin', jitter='None')>") def test_str(self, trigger): assert str(trigger) == "interval[0:00:01]" @@ -384,9 +509,37 @@ class TestIntervalTrigger(object): """Test that the trigger is pickleable.""" trigger = IntervalTrigger(weeks=2, days=6, minutes=13, seconds=2, - start_date=date(2016, 4, 3), timezone=timezone) + start_date=date(2016, 4, 3), timezone=timezone, + jitter=12) data = pickle.dumps(trigger, 2) trigger2 = pickle.loads(data) for attr in IntervalTrigger.__slots__: assert getattr(trigger2, attr) == getattr(trigger, attr) + + def test_jitter_produces_different_valid_results(self, timezone): + trigger = IntervalTrigger(seconds=5, timezone=timezone, jitter=3) + now = datetime.now(timezone) + + results = set() + for _ in range(0, 100): + next_fire_time = trigger.get_next_fire_time(None, now) + results.add(next_fire_time) + assert timedelta(seconds=2) <= (next_fire_time - now) <= timedelta(seconds=8) + assert 1 < len(results) + + @pytest.mark.parametrize('trigger_args, start_date, start_date_dst, correct_next_date', [ + ({'hours': 1}, datetime(2013, 3, 10, 1, 35), False, datetime(2013, 3, 10, 3, 35)), + ({'hours': 1}, datetime(2013, 11, 3, 1, 35), True, datetime(2013, 11, 3, 1, 35)) + ], ids=['interval_spring', 'interval_autumn']) + def test_jitter_dst_change(self, trigger_args, start_date, start_date_dst, correct_next_date): + timezone = pytz.timezone('US/Eastern') + epsilon = timedelta(seconds=1) + start_date = timezone.localize(start_date, is_dst=start_date_dst) + trigger = IntervalTrigger(timezone=timezone, start_date=start_date, jitter=5, + **trigger_args) + correct_next_date = timezone.localize(correct_next_date, is_dst=not start_date_dst) + + for _ in range(0, 100): + next_fire_time = trigger.get_next_fire_time(None, start_date + epsilon) + assert abs(next_fire_time - correct_next_date) <= timedelta(seconds=5) |