Skip to content

Commit a14e2b5

Browse files
authored
Add support for a concurrency policy (#1)
* Add MIT license * Fix GitHub Actions workflow for package testing
1 parent e165dcb commit a14e2b5

File tree

9 files changed

+104
-29
lines changed

9 files changed

+104
-29
lines changed

.github/workflows/python-package.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ jobs:
2626
- name: Install dependencies
2727
run: |
2828
python -m pip install --upgrade pip
29-
pip install flake8 pytest
30-
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
29+
pip install flake8 freezegun pytest
30+
pip install .
3131
- name: Lint with flake8
3232
run: |
3333
# stop the build if there are Python syntax errors or undefined names

.travis.yml

-9
This file was deleted.

CHANGELOG.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
All notable changes to this project will be documented in this file.
3+
4+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [Unreleased]
8+
9+
## [0.1.0] - 2020-11-20
10+
### Added
11+
- Support for setting a concurrency policy. The default policy is to wait for
12+
the current worker to complete and immediately spawn a new one if the next start
13+
time has lapsed. The other available policies are ``ConcurrencyPolic.SKIP`` which
14+
will skip the lapsed schedule and ``ConcurrencyPolicy.ALLOW`` which will launch
15+
a new worker concurrently if one is still running.
16+
- MIT license.
17+
18+
## [0.0.1] - 2020-11-19
19+
### Added
20+
- Cron entrypoint that supports timezone-aware cron schedules.
21+
22+
[Unreleased]: https://github.com/bradshjg/nameko-cron/compare/0.1.0...HEAD
23+
[0.1.0]: https://github.com/bradshjg/nameko-cron/compare/0.0.1...0.1.0
24+
[0.0.1]: https://github.com/bradshjg/nameko-cron/releases/tag/0.0.1

LICENSE.txt

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Copyright 2020 James Bradshaw
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
4+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
5+
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
6+
and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7+
8+
The above copyright notice and this permission notice shall be included in all copies or substantial portions
9+
of the Software.
10+
11+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
12+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
13+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
14+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
15+
IN THE SOFTWARE.

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,13 @@ class Service:
3232
# executes every day at noon America/Chicago time
3333
print("pong")
3434
```
35+
36+
by default, if a worker takes longer than the next scheduled run the worker will wait until
37+
the task is complete before immediately launching a new worker. This behavior can be controlled
38+
via the ``concurrency`` keyword argument.
39+
40+
``ConcurrencyPolicy.WAIT`` is that default behavior.
41+
42+
``ConcurrencyPolicy.ALLOW`` will spawn a worker regardless of whether existing workers are still running.
43+
44+
``ConcurrencyPolicy.SKIP`` will skip a run if the previous worker lapsed the next scheduled run.

nameko_cron/__init__.py

+26-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
from enum import Enum
23
import time
34
from logging import getLogger
45

@@ -13,11 +14,22 @@
1314
_log = getLogger(__name__)
1415

1516

17+
class ConcurrencyPolicy(Enum):
18+
ALLOW = 'allow'
19+
SKIP = 'skip'
20+
WAIT = 'wait'
21+
22+
1623
class Cron(Entrypoint):
17-
def __init__(self, schedule, tz=None, **kwargs):
24+
def __init__(self, schedule: str, tz: str = None, concurrency: str = ConcurrencyPolicy.WAIT, **kwargs):
1825
"""
1926
Cron entrypoint. Fires according to a (possibly timezone-aware)
2027
cron schedule. If no timezone info is passed, the default is UTC.
28+
Set ``concurrency`` to ``ConcurrencyPolicy.ALLOW`` to allow multiple workers
29+
to run simultaneously. Set ``concurrency`` to ``ConcurrencyPolicy.SKIP`` to
30+
skip lapsed scheduled runs. The default behavior (``ConcurrencyPolicy.WAIT``)
31+
is to wait until the running worker completes and immediately spawn another
32+
if the schedule has lapsed.
2133
2234
Example::
2335
@@ -32,6 +44,7 @@ def ping(self):
3244
"""
3345
self.schedule = schedule
3446
self.tz = tz
47+
self.concurrency = concurrency
3548
self.should_stop = Event()
3649
self.worker_complete = Event()
3750
self.gt = None
@@ -74,10 +87,17 @@ def _run(self):
7487

7588
self.handle_timer_tick()
7689

77-
self.worker_complete.wait()
78-
self.worker_complete.reset()
90+
if self.concurrency != ConcurrencyPolicy.ALLOW:
91+
self.worker_complete.wait()
92+
self.worker_complete.reset()
7993

8094
sleep_time = next(interval)
95+
print(sleep_time)
96+
97+
# a sleep time of zero represents that we've elapsed the next start time, so
98+
# if the user set the policy to skip, we need to update the interval again.
99+
if self.concurrency == ConcurrencyPolicy.SKIP and sleep_time == 0:
100+
sleep_time = next(interval)
81101

82102
def handle_timer_tick(self):
83103
args = ()
@@ -92,7 +112,9 @@ def handle_timer_tick(self):
92112
self, args, kwargs, handle_result=self.handle_result)
93113

94114
def handle_result(self, worker_ctx, result, exc_info):
95-
self.worker_complete.send()
115+
# we only care about the worker completion if we're going to be waiting for it.
116+
if self.concurrency != ConcurrencyPolicy.ALLOW:
117+
self.worker_complete.send()
96118
return result, exc_info
97119

98120

setup.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setuptools.setup(
77
name="nameko-cron",
8-
version="0.0.1",
8+
version="0.1.0",
99
author="bradshjg",
1010
author_email="[email protected]",
1111
description="Nameko cron extension",
@@ -15,8 +15,14 @@
1515
packages=setuptools.find_packages(exclude=['tests']),
1616
classifiers=[
1717
"Programming Language :: Python :: 3",
18+
"Programming Language :: Python :: 3.6",
19+
"Programming Language :: Python :: 3.7",
20+
"Programming Language :: Python :: 3.8",
1821
"License :: OSI Approved :: MIT License",
1922
"Operating System :: OS Independent",
23+
"Topic :: Internet",
24+
"Topic :: Software Development :: Libraries :: Python Modules",
25+
"Intended Audience :: Developers",
2026
],
2127
python_requires='>=3.6',
2228
install_requires=[

tests/test_cron.py

+20-12
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,51 @@
33
import eventlet
44
import freezegun
55
import pytest
6-
from mock import Mock
6+
from unittest.mock import Mock
77

88
from nameko.testing.services import get_extension
99
from nameko.testing.utils import wait_for_call
1010

11-
from nameko_cron import Cron, cron
11+
from nameko_cron import ConcurrencyPolicy, Cron, cron
1212

1313

1414
@pytest.fixture
1515
def tracker():
1616
return Mock()
1717

1818

19-
def test_cron_runs(container_factory, tracker):
19+
@pytest.mark.parametrize("timeout,concurrency,task_time,expected_calls", [
20+
# the cron schedule is set to spawn a worker every second
21+
(5, ConcurrencyPolicy.WAIT, 0, 5), # a short-lived worker run at 0, 1, 2, 3, 4, 5
22+
(5, ConcurrencyPolicy.WAIT, 2, 3), # a long-lived worker should fire at 0, 2, 4
23+
(5, ConcurrencyPolicy.ALLOW, 10, 5), # if concurrency is permitted, new workers spawn alongside existing ones
24+
(5, ConcurrencyPolicy.SKIP, 1.5, 3), # skipping should run at 0, 2, and 4
25+
(5, ConcurrencyPolicy.WAIT, 1.5, 4), # run at 0, 1.5, 3, 4.5 (always behind)
26+
])
27+
def test_cron_runs(timeout, concurrency, task_time, expected_calls, container_factory, tracker):
2028
"""Test running the cron main loop."""
21-
timeout = 2.0
22-
2329
class Service(object):
2430
name = "service"
2531

26-
@cron('* * * * * *')
32+
@cron('* * * * * *', concurrency=concurrency)
2733
def tick(self):
28-
eventlet.sleep(0)
2934
tracker()
35+
eventlet.sleep(task_time)
3036

3137
container = container_factory(Service, {})
3238

33-
# Check that Timer instance is initialized correctly
39+
# Check that Cron instance is initialized correctly
3440
instance = get_extension(container, Cron)
3541
assert instance.schedule == '* * * * * *'
3642
assert instance.tz is None
43+
assert instance.concurrency == concurrency
3744

38-
container.start()
39-
eventlet.sleep(timeout)
40-
container.stop()
45+
with freezegun.freeze_time('2020-11-20 23:59:59.5', tick=True):
46+
container.start()
47+
eventlet.sleep(timeout)
48+
container.stop()
4149

42-
assert tracker.call_count == 2
50+
assert tracker.call_count == expected_calls
4351

4452

4553
@pytest.mark.parametrize("timezone,expected_first_interval_hours", [

tox.ini

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ envlist = py36
44
[testenv]
55
deps =
66
freezegun
7-
mock
87
pytest
98
commands =
109
pytest

0 commit comments

Comments
 (0)