Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions docs/source/jobs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,15 @@ Long form::
Cron
^^^^

Schedule a job using cron syntax. Tron supports predefined schedules, ranges,
and lists for each field. It supports the *L* in day of month field only (which
schedules the job on the last day of the month). Only one of the day fields
(day of month and day of week) can have a value.
Schedule a job using cron syntax, powered by
`croniter <https://github.com/pallets-eco/croniter>`_. Tron supports the full
range of cron expressions including ranges, lists, steps, predefined schedules
(``@daily``, ``@hourly``, etc.), month/weekday names, ``L`` (last day of
month), and ``#`` for Nth weekday of month (e.g. ``MON#1`` for the first
Monday).

Both day-of-month and day-of-week may be specified simultaneously (interpreted
as a union, per POSIX cron), though this is rarely what you want.

Short form::

Expand All @@ -304,16 +308,49 @@ Short form::

schedule: "cron 0 3-6 * * *" # Every hour between 3am and 6am

::

schedule: "cron 30 4 L * *" # The last day of the month at 4:30am

::

schedule: "cron 0 9 * * MON#1" # The first Monday of every month at 9am

::

schedule: "cron 0 9 * * L5" # The last Friday of every month at 9am

Long form::

schedule: # long form
schedule:
type: "cron"
value: "30 4 L * *" # The last day of the month at 4:30am
value: "30 4 L * *"

Hashed expressions
""""""""""""""""""

Croniter supports Jenkins-style ``H`` (hashed) expressions for distributing
jobs evenly without manual coordination. The hash is seeded by the job name,
so the resolved time is consistent across restarts but different across jobs.

::

schedule: "cron H H * * *" # Daily at a consistent but distributed time
schedule: "cron H/15 * * * *" # Every 15 minutes, offset per job
schedule: "cron H H(0-5) * * *" # Daily between 00:00 and 05:59, hashed per job

This is useful when many jobs share the same logical schedule (e.g. "daily")
but you don't want them all firing at midnight.


Complex
^^^^^^^

.. warning::

We plan to remove this style of schedule in the near future.
You should now be able to express all these sorts of schedules with cron syntax.

More powerful version of the daily scheduler based on the one used by Google
App Engine's cron library. To use this scheduler, use a string in this format
as the schedule::
Expand Down
1 change: 1 addition & 0 deletions requirements-dev-minimal.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pytest-asyncio
requirements-tools
types-boto3
types-cachetools
types-croniter
types-psutil
types-pytz
types-PyYAML
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tomli==2.0.1
types-awscrt==0.27.2
types-boto3==1.0.2
types-cachetools==5.5.0.20240820
types-croniter==6.2.2.20260518
types-psutil==6.1.0.20241221
types-pytz==2024.2.0.20240913
types-PyYAML==6.0.12
Expand Down
1 change: 1 addition & 0 deletions requirements-minimal.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ addict # not sure why check-requirements is not picking this up from task_proce
argcomplete
boto3
bsddb3
croniter
cryptography
dataclasses
ecdsa>=0.13.3
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ certifi==2022.12.7
cffi==1.15.0
charset-normalizer==2.0.12
constantly==15.1.0
croniter==6.2.2
cryptography==41.0.5
dataclasses==0.6
decorator==4.4.0
Expand Down
12 changes: 7 additions & 5 deletions tests/config/schedule_parse_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,17 @@ def validate(self, line):

def test_valid_config(self):
config = self.validate("5 0 L * *")
assert_equal(config.minutes, [5])
assert_equal(config.months, None)
assert_equal(config.monthdays, ["LAST"])
assert_equal(config.original, "5 0 L * *")

def test_valid_config_with_both_dom_and_dow(self):
config = self.validate("10 14 15-21 * 5")
assert_equal(config.original, "10 14 15-21 * 5")

def test_invalid_config(self):
assert_raises(ConfigError, self.validate, "* * *")

def test_monthdays_and_weekdays_rejected(self):
assert_raises(ConfigError, self.validate, "10 14 15-21 * 5")
def test_impossible_date_rejected(self):
assert_raises(ConfigError, self.validate, "0 0 30 2 *")


class TestValidDailyScheduler(TestCase):
Expand Down
11 changes: 11 additions & 0 deletions tests/scheduler_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import datetime
from unittest import mock

import pytest
import pytz
from croniter.croniter import CroniterBadDateError

from testifycompat import assert_equal
from testifycompat import assert_gt
Expand Down Expand Up @@ -48,6 +50,15 @@ def test_daily_scheduler(self):
assert_equal(str(sched), "daily 17:32 MWF")


class TestCronSchedulerImpossibleDate(TestCase):
def test_impossible_date_raises_instead_of_infinite_loop(self):
"""Feb 30 can never match. Croniter must raise rather than loop forever."""
sched = scheduler.CronScheduler(cron_expression="0 0 30 2 *")
start_time = datetime.datetime(2024, 1, 1)
with pytest.raises(CroniterBadDateError):
sched.next_run_time(start_time)


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on repurposing some of these GeneralScheduler tests for CronScheduler?
I think some of these should be carried forward. E.g.

  • test_fall_back should look at CronScheduler("30 1 * * *")
  • test_spring_forward should look at CronScheduler("30 2 * * *") to ensure we shift nonexistent times forward
  • test_handles_unsetting_the_time_zone and test_handles_changing_the_time_zone both test behaviour we should maintain

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeez, test_handles_unsetting_the_time_zone is just incorrect in its current form. I like the spirit of it, but imo it shouldn't start with UTC and it shouldn't assert hour == 0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JEEZ, test_handles_changing_the_time_zone is also funky. I don't think this should look at datetime.now and I very much disagree with asserting on just the hour.

class GeneralSchedulerTestCase(testingutils.MockTimeTestCase):

now = datetime.datetime.now().replace(hour=15, minute=0)
Expand Down
188 changes: 0 additions & 188 deletions tests/utils/crontab_test.py

This file was deleted.

29 changes: 16 additions & 13 deletions tron/config/schedule_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import re
from collections import namedtuple

from croniter import croniter
from croniter.croniter import CroniterBadDateError

from tron.config import config_utils
from tron.config import ConfigError
from tron.config import schema
from tron.utils import crontab

ConfigGenericSchedule = schema.config_object_factory(
"ConfigGenericSchedule",
Expand All @@ -24,7 +26,7 @@

ConfigCronScheduler = namedtuple(
"ConfigCronScheduler",
"original minutes hours monthdays months weekdays ordinals jitter",
"original jitter",
)

ConfigDailyScheduler = namedtuple(
Expand Down Expand Up @@ -289,18 +291,19 @@ def parse_groc_expression(config, config_context):

def valid_cron_scheduler(config, config_context):
"""Parse a cron schedule."""
try:
crontab_kwargs = crontab.parse_crontab(config.value)
if crontab_kwargs["monthdays"] is not None and crontab_kwargs["weekdays"] is not None:
raise ValueError("cannot supply both monthdays and weekdays")
return ConfigCronScheduler(
original=config.value,
jitter=config.jitter,
**crontab_kwargs,
)
except ValueError as e:
expression = re.sub(r"\s*,\s*", ",", config.value.strip())
if not croniter.is_valid(expression, hash_id="validation_placeholder"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use the actual hash_id instead of a placeholder? If we have someone doing 0 0 H 2 * we could end up with 30/31 since Croniter hashes H across the full 1–31 range. A less likely but funnier risk would be 0 0 31 H *

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting point.

msg = "Invalid cron scheduler %s: %s"
raise ConfigError(msg % (config_context.path, e))
raise ConfigError(msg % (config_context.path, expression))
try:
croniter(expression, hash_id="validation_placeholder").get_next()
except CroniterBadDateError:
msg = "Cron expression %s at %s will never match a valid date"
raise ConfigError(msg % (expression, config_context.path))
return ConfigCronScheduler(
original=expression,
jitter=config.jitter,
)


schedulers = {
Expand Down
Loading
Loading