summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGilbert Gilb's <gilbsgilbs@users.noreply.github.com>2017-12-10 19:43:22 +0100
committerAlex Grönholm <alex.gronholm@nextday.fi>2017-12-10 20:43:22 +0200
commit60c757c9d95c50d66ec2e1abc03b459b45702e58 (patch)
tree32f6f50e97e1c1e650c1e099a5f77d9526b327f2
parentc6c5031276c3b864e127c637d2bdd48138a0b426 (diff)
downloadapscheduler-60c757c9d95c50d66ec2e1abc03b459b45702e58.tar.gz
Implement random jitter option for CronTrigger and IntervalTrigger (#258)
-rw-r--r--apscheduler/triggers/base.py29
-rw-r--r--apscheduler/triggers/cron/__init__.py26
-rw-r--r--apscheduler/triggers/interval.py27
-rw-r--r--docs/modules/triggers/cron.rst8
-rw-r--r--docs/modules/triggers/interval.rst8
-rw-r--r--tests/test_triggers.py185
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)