Repository: agronholm/apscheduler

Issue:
Feature request: allow interval jobs to be offset by a random value
When jobs are configured to execute at certain times, they tend to coalesce at one particular time. For example, executing 10 jobs at the top of the hour, another 10 once in at noon and once at midnight, and a third job that executes every minute will cause all of these jobs to run at the same time at noon and midnight, potentially causing a bottleneck in the number of threads.

This could be solved by introducing a new parameter to the interval trigger that would define a value in seconds by which each job would be offset.

Assuming these new imports:
``` python
from operator import add, sub
from random import randrange, choice
```
The constructor could have a new parameter value (`random_offset`) that would be at most equivalent to the configured interval:
``` python
self.random_offset = min(self.interval_length, random_offset)
```
Then, the `get_next_fire_time()` function could check if an offset is defined and modify the next execution time accordingly:
``` python
if self.random_offset:
    offset = timedelta(seconds=randrange(0, self.random_offset))
    op = choice((add, sub))
    next_fire_time = max(datetime.now(self.timezone), op(next_fire_time, offset))
```
I understand all this can be worked around by setting custom start times for each group of jobs so that they don't intersect but having an easy-to-configure parameter would be cool too. The UNIX Anacron scheduler uses a similar mechanic with the [`RANDOM_DELAY` option](http://man7.org/linux/man-pages/man5/anacrontab.5.html).

I did notice during testing of the above code that `get_next_fire_time()` is called both from `_get_run_times()` (in `job.py`) and `_process_jobs` in (in `base.py`), producing different times for each. I'm not sure if it's significant that these functions both receive the same next fire time.

Anyway, is this something you'd consider merging if I were to submit a PR? Thank you!

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/14586525/badge)](https://coveralls.io/builds/14586525)

Coverage decreased (-0.04%) to 93.351% when pulling **b5233b3ec0a8525eef22e90f8abddf690dd759ff on gilbsgilbs:jitter** into **c6c5031276c3b864e127c637d2bdd48138a0b426 on agronholm:master**.

agronholm: The changes I requested for the interval trigger of course apply to the cron trigger as well.
coveralls: 
[![Coverage Status](https://coveralls.io/builds/14587374/badge)](https://coveralls.io/builds/14587374)

Coverage increased (+0.05%) to 93.442% when pulling **1e53344018d1fd5568f0a0597846ec46a65b4805 on gilbsgilbs:jitter** into **c6c5031276c3b864e127c637d2bdd48138a0b426 on agronholm:master**.


Patch:
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)


Test patch:
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)