pydocket 0.2.1__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pydocket might be problematic. Click here for more details.
- {pydocket-0.2.1 → pydocket-0.3.1}/.pre-commit-config.yaml +2 -2
- {pydocket-0.2.1 → pydocket-0.3.1}/PKG-INFO +1 -1
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/__init__.py +2 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/dependencies.py +28 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/execution.py +16 -1
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/instrumentation.py +6 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/worker.py +55 -15
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/conftest.py +7 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_fundamentals.py +102 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_worker.py +78 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.github/codecov.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/ci.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/publish.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/.gitignore +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/LICENSE +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/README.md +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/README.md +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/driver.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/producer.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/run +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/chaos/tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/pyproject.toml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/__main__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/annotations.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/cli.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/docket.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/py.typed +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/.gitignore +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/start +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/stop +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/conftest.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_module.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_striking.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_version.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_worker.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_workers.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_dependencies.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_docket.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_instrumentation.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_striking.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.1}/uv.lock +0 -0
|
@@ -24,13 +24,13 @@ repos:
|
|
|
24
24
|
hooks:
|
|
25
25
|
- id: pyright
|
|
26
26
|
name: pyright (docket package)
|
|
27
|
-
entry:
|
|
27
|
+
entry: pyright --verifytypes docket --ignoreexternal
|
|
28
28
|
language: system
|
|
29
29
|
types: [python]
|
|
30
30
|
pass_filenames: false
|
|
31
31
|
- id: pyright
|
|
32
32
|
name: pyright (source and tests)
|
|
33
|
-
entry:
|
|
33
|
+
entry: pyright tests
|
|
34
34
|
language: system
|
|
35
35
|
types: [python]
|
|
36
36
|
pass_filenames: false
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydocket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A distributed background task system for Python functions
|
|
5
5
|
Project-URL: Homepage, https://github.com/chrisguidry/docket
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
|
|
@@ -14,6 +14,7 @@ from .dependencies import (
|
|
|
14
14
|
CurrentExecution,
|
|
15
15
|
CurrentWorker,
|
|
16
16
|
ExponentialRetry,
|
|
17
|
+
Perpetual,
|
|
17
18
|
Retry,
|
|
18
19
|
TaskKey,
|
|
19
20
|
TaskLogger,
|
|
@@ -34,5 +35,6 @@ __all__ = [
|
|
|
34
35
|
"Retry",
|
|
35
36
|
"ExponentialRetry",
|
|
36
37
|
"Logged",
|
|
38
|
+
"Perpetual",
|
|
37
39
|
"__version__",
|
|
38
40
|
]
|
|
@@ -126,6 +126,34 @@ class ExponentialRetry(Retry):
|
|
|
126
126
|
return retry
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
class Perpetual(Dependency):
|
|
130
|
+
single = True
|
|
131
|
+
|
|
132
|
+
every: timedelta
|
|
133
|
+
args: tuple[Any, ...]
|
|
134
|
+
kwargs: dict[str, Any]
|
|
135
|
+
cancelled: bool
|
|
136
|
+
|
|
137
|
+
def __init__(self, every: timedelta = timedelta(0)) -> None:
|
|
138
|
+
self.every = every
|
|
139
|
+
self.cancelled = False
|
|
140
|
+
|
|
141
|
+
def __call__(
|
|
142
|
+
self, docket: Docket, worker: Worker, execution: Execution
|
|
143
|
+
) -> "Perpetual":
|
|
144
|
+
perpetual = Perpetual(every=self.every)
|
|
145
|
+
perpetual.args = execution.args
|
|
146
|
+
perpetual.kwargs = execution.kwargs
|
|
147
|
+
return perpetual
|
|
148
|
+
|
|
149
|
+
def cancel(self) -> None:
|
|
150
|
+
self.cancelled = True
|
|
151
|
+
|
|
152
|
+
def perpetuate(self, *args: Any, **kwargs: Any) -> None:
|
|
153
|
+
self.args = args
|
|
154
|
+
self.kwargs = kwargs
|
|
155
|
+
|
|
156
|
+
|
|
129
157
|
def get_dependency_parameters(
|
|
130
158
|
function: Callable[..., Awaitable[Any]],
|
|
131
159
|
) -> dict[str, Dependency]:
|
|
@@ -183,18 +183,33 @@ TaskStrikes = dict[str, ParameterStrikes]
|
|
|
183
183
|
class StrikeList:
|
|
184
184
|
task_strikes: TaskStrikes
|
|
185
185
|
parameter_strikes: ParameterStrikes
|
|
186
|
+
_conditions: list[Callable[[Execution], bool]]
|
|
186
187
|
|
|
187
188
|
def __init__(self) -> None:
|
|
188
189
|
self.task_strikes = {}
|
|
189
190
|
self.parameter_strikes = {}
|
|
191
|
+
self._conditions = [self._matches_task_or_parameter_strike]
|
|
192
|
+
|
|
193
|
+
def add_condition(self, condition: Callable[[Execution], bool]) -> None:
|
|
194
|
+
"""Adds a temporary condition that indicates an execution is stricken."""
|
|
195
|
+
self._conditions.insert(0, condition)
|
|
196
|
+
|
|
197
|
+
def remove_condition(self, condition: Callable[[Execution], bool]) -> None:
|
|
198
|
+
"""Adds a temporary condition that indicates an execution is stricken."""
|
|
199
|
+
assert condition is not self._matches_task_or_parameter_strike
|
|
200
|
+
self._conditions.remove(condition)
|
|
190
201
|
|
|
191
202
|
def is_stricken(self, execution: Execution) -> bool:
|
|
192
203
|
"""
|
|
193
|
-
Checks if an execution is stricken based on task
|
|
204
|
+
Checks if an execution is stricken based on task, parameter, or temporary
|
|
205
|
+
conditions.
|
|
194
206
|
|
|
195
207
|
Returns:
|
|
196
208
|
bool: True if the execution is stricken, False otherwise.
|
|
197
209
|
"""
|
|
210
|
+
return any(condition(execution) for condition in self._conditions)
|
|
211
|
+
|
|
212
|
+
def _matches_task_or_parameter_strike(self, execution: Execution) -> bool:
|
|
198
213
|
function_name = execution.function.__name__
|
|
199
214
|
|
|
200
215
|
# Check if the entire task is stricken (without parameter conditions)
|
|
@@ -70,6 +70,12 @@ TASKS_RETRIED = meter.create_counter(
|
|
|
70
70
|
unit="1",
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
+
TASKS_PERPETUATED = meter.create_counter(
|
|
74
|
+
"docket_tasks_perpetuated",
|
|
75
|
+
description="How many tasks that have been self-perpetuated",
|
|
76
|
+
unit="1",
|
|
77
|
+
)
|
|
78
|
+
|
|
73
79
|
TASK_DURATION = meter.create_histogram(
|
|
74
80
|
"docket_task_duration",
|
|
75
81
|
description="How long tasks take to complete",
|
|
@@ -7,7 +7,6 @@ from types import TracebackType
|
|
|
7
7
|
from typing import (
|
|
8
8
|
TYPE_CHECKING,
|
|
9
9
|
Any,
|
|
10
|
-
Callable,
|
|
11
10
|
Mapping,
|
|
12
11
|
Protocol,
|
|
13
12
|
Self,
|
|
@@ -36,6 +35,7 @@ from .instrumentation import (
|
|
|
36
35
|
TASK_PUNCTUALITY,
|
|
37
36
|
TASKS_COMPLETED,
|
|
38
37
|
TASKS_FAILED,
|
|
38
|
+
TASKS_PERPETUATED,
|
|
39
39
|
TASKS_RETRIED,
|
|
40
40
|
TASKS_RUNNING,
|
|
41
41
|
TASKS_STARTED,
|
|
@@ -68,7 +68,6 @@ class Worker:
|
|
|
68
68
|
redelivery_timeout: timedelta
|
|
69
69
|
reconnection_delay: timedelta
|
|
70
70
|
minimum_check_interval: timedelta
|
|
71
|
-
_strike_conditions: list[Callable[[Execution], bool]] = []
|
|
72
71
|
|
|
73
72
|
def __init__(
|
|
74
73
|
self,
|
|
@@ -86,13 +85,9 @@ class Worker:
|
|
|
86
85
|
self.reconnection_delay = reconnection_delay
|
|
87
86
|
self.minimum_check_interval = minimum_check_interval
|
|
88
87
|
|
|
89
|
-
self._strike_conditions = [
|
|
90
|
-
docket.strike_list.is_stricken,
|
|
91
|
-
]
|
|
92
|
-
|
|
93
88
|
async def __aenter__(self) -> Self:
|
|
94
89
|
self._heartbeat_task = asyncio.create_task(self._heartbeat())
|
|
95
|
-
|
|
90
|
+
self._execution_counts = {}
|
|
96
91
|
return self
|
|
97
92
|
|
|
98
93
|
async def __aexit__(
|
|
@@ -101,6 +96,8 @@ class Worker:
|
|
|
101
96
|
exc_value: BaseException | None,
|
|
102
97
|
traceback: TracebackType | None,
|
|
103
98
|
) -> None:
|
|
99
|
+
del self._execution_counts
|
|
100
|
+
|
|
104
101
|
self._heartbeat_task.cancel()
|
|
105
102
|
try:
|
|
106
103
|
await self._heartbeat_task
|
|
@@ -161,6 +158,8 @@ class Worker:
|
|
|
161
158
|
"""Run the worker indefinitely."""
|
|
162
159
|
return await self._run(forever=True) # pragma: no cover
|
|
163
160
|
|
|
161
|
+
_execution_counts: dict[str, int]
|
|
162
|
+
|
|
164
163
|
async def run_at_most(self, iterations_by_key: Mapping[str, int]) -> None:
|
|
165
164
|
"""
|
|
166
165
|
Run the worker until there are no more tasks to process, but limit specified
|
|
@@ -172,23 +171,25 @@ class Worker:
|
|
|
172
171
|
Args:
|
|
173
172
|
iterations_by_key: Maps task keys to their maximum allowed executions
|
|
174
173
|
"""
|
|
175
|
-
|
|
174
|
+
self._execution_counts = {key: 0 for key in iterations_by_key}
|
|
176
175
|
|
|
177
176
|
def has_reached_max_iterations(execution: Execution) -> bool:
|
|
178
|
-
|
|
177
|
+
key = execution.key
|
|
178
|
+
|
|
179
|
+
if key not in iterations_by_key:
|
|
179
180
|
return False
|
|
180
181
|
|
|
181
|
-
if
|
|
182
|
+
if self._execution_counts[key] >= iterations_by_key[key]:
|
|
182
183
|
return True
|
|
183
184
|
|
|
184
|
-
execution_counts[execution.key] += 1
|
|
185
185
|
return False
|
|
186
186
|
|
|
187
|
-
self.
|
|
187
|
+
self.docket.strike_list.add_condition(has_reached_max_iterations)
|
|
188
188
|
try:
|
|
189
189
|
await self.run_until_finished()
|
|
190
190
|
finally:
|
|
191
|
-
self.
|
|
191
|
+
self.docket.strike_list.remove_condition(has_reached_max_iterations)
|
|
192
|
+
self._execution_counts = {}
|
|
192
193
|
|
|
193
194
|
async def _run(self, forever: bool = False) -> None:
|
|
194
195
|
logger.info("Starting worker %r with the following tasks:", self.name)
|
|
@@ -379,12 +380,15 @@ class Worker:
|
|
|
379
380
|
arrow = "↬" if execution.attempt > 1 else "↪"
|
|
380
381
|
call = execution.call_repr()
|
|
381
382
|
|
|
382
|
-
if
|
|
383
|
+
if self.docket.strike_list.is_stricken(execution):
|
|
383
384
|
arrow = "🗙"
|
|
384
385
|
logger.warning("%s %s", arrow, call, extra=log_context)
|
|
385
386
|
TASKS_STRICKEN.add(1, counter_labels | {"docket.where": "worker"})
|
|
386
387
|
return
|
|
387
388
|
|
|
389
|
+
if execution.key in self._execution_counts:
|
|
390
|
+
self._execution_counts[execution.key] += 1
|
|
391
|
+
|
|
388
392
|
dependencies = self._get_dependencies(execution)
|
|
389
393
|
|
|
390
394
|
context = propagate.extract(message, getter=message_getter)
|
|
@@ -424,12 +428,20 @@ class Worker:
|
|
|
424
428
|
TASKS_SUCCEEDED.add(1, counter_labels)
|
|
425
429
|
duration = datetime.now(timezone.utc) - start
|
|
426
430
|
log_context["duration"] = duration.total_seconds()
|
|
427
|
-
|
|
431
|
+
rescheduled = await self._perpetuate_if_requested(
|
|
432
|
+
execution, dependencies, duration
|
|
433
|
+
)
|
|
434
|
+
arrow = "↫" if rescheduled else "↩"
|
|
435
|
+
logger.info("%s [%s] %s", arrow, duration, call, extra=log_context)
|
|
428
436
|
except Exception:
|
|
429
437
|
TASKS_FAILED.add(1, counter_labels)
|
|
430
438
|
duration = datetime.now(timezone.utc) - start
|
|
431
439
|
log_context["duration"] = duration.total_seconds()
|
|
432
440
|
retried = await self._retry_if_requested(execution, dependencies)
|
|
441
|
+
if not retried:
|
|
442
|
+
retried = await self._perpetuate_if_requested(
|
|
443
|
+
execution, dependencies, duration
|
|
444
|
+
)
|
|
433
445
|
arrow = "↫" if retried else "↩"
|
|
434
446
|
logger.exception("%s [%s] %s", arrow, duration, call, extra=log_context)
|
|
435
447
|
finally:
|
|
@@ -481,6 +493,34 @@ class Worker:
|
|
|
481
493
|
|
|
482
494
|
return False
|
|
483
495
|
|
|
496
|
+
async def _perpetuate_if_requested(
|
|
497
|
+
self, execution: Execution, dependencies: dict[str, Any], duration: timedelta
|
|
498
|
+
) -> bool:
|
|
499
|
+
from .dependencies import Perpetual
|
|
500
|
+
|
|
501
|
+
perpetuals = [
|
|
502
|
+
perpetual
|
|
503
|
+
for perpetual in dependencies.values()
|
|
504
|
+
if isinstance(perpetual, Perpetual)
|
|
505
|
+
]
|
|
506
|
+
if not perpetuals:
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
perpetual = perpetuals[0]
|
|
510
|
+
|
|
511
|
+
if perpetual.cancelled:
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
now = datetime.now(timezone.utc)
|
|
515
|
+
execution.when = max(now, now + perpetual.every - duration)
|
|
516
|
+
execution.args = perpetual.args
|
|
517
|
+
execution.kwargs = perpetual.kwargs
|
|
518
|
+
|
|
519
|
+
await self.docket.schedule(execution)
|
|
520
|
+
|
|
521
|
+
TASKS_PERPETUATED.add(1, {**self.labels(), **execution.specific_labels()})
|
|
522
|
+
return True
|
|
523
|
+
|
|
484
524
|
@property
|
|
485
525
|
def workers_set(self) -> str:
|
|
486
526
|
return self.docket.workers_set
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fcntl
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
import socket
|
|
4
5
|
import time
|
|
@@ -20,6 +21,12 @@ from docket import Docket, Worker
|
|
|
20
21
|
REDIS_VERSION = os.environ.get("REDIS_VERSION", "7.4")
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def log_level(caplog: pytest.LogCaptureFixture) -> Generator[None, None, None]:
|
|
26
|
+
with caplog.at_level(logging.DEBUG):
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
|
|
23
30
|
@pytest.fixture
|
|
24
31
|
def now() -> Callable[[], datetime]:
|
|
25
32
|
return partial(datetime.now, timezone.utc)
|
|
@@ -22,6 +22,7 @@ from docket import (
|
|
|
22
22
|
Execution,
|
|
23
23
|
ExponentialRetry,
|
|
24
24
|
Logged,
|
|
25
|
+
Perpetual,
|
|
25
26
|
Retry,
|
|
26
27
|
TaskKey,
|
|
27
28
|
TaskLogger,
|
|
@@ -854,3 +855,104 @@ async def test_adding_task_with_unbindable_arguments(
|
|
|
854
855
|
await worker.run_until_finished()
|
|
855
856
|
|
|
856
857
|
assert "got an unexpected keyword argument 'd'" in caplog.text
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
async def test_perpetual_tasks(docket: Docket, worker: Worker):
|
|
861
|
+
"""Perpetual tasks should reschedule themselves forever"""
|
|
862
|
+
|
|
863
|
+
calls = 0
|
|
864
|
+
|
|
865
|
+
async def perpetual_task(
|
|
866
|
+
a: str,
|
|
867
|
+
b: int,
|
|
868
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
869
|
+
):
|
|
870
|
+
assert a == "a"
|
|
871
|
+
assert b == 2
|
|
872
|
+
|
|
873
|
+
assert isinstance(perpetual, Perpetual)
|
|
874
|
+
|
|
875
|
+
assert perpetual.every == timedelta(milliseconds=50)
|
|
876
|
+
|
|
877
|
+
nonlocal calls
|
|
878
|
+
calls += 1
|
|
879
|
+
|
|
880
|
+
execution = await docket.add(perpetual_task)(a="a", b=2)
|
|
881
|
+
|
|
882
|
+
await worker.run_at_most({execution.key: 3})
|
|
883
|
+
|
|
884
|
+
assert calls == 3
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
async def test_perpetual_tasks_can_cancel_themselves(docket: Docket, worker: Worker):
|
|
888
|
+
"""A perpetual task can request its own cancellation"""
|
|
889
|
+
calls = 0
|
|
890
|
+
|
|
891
|
+
async def perpetual_task(
|
|
892
|
+
a: str,
|
|
893
|
+
b: int,
|
|
894
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
895
|
+
):
|
|
896
|
+
assert a == "a"
|
|
897
|
+
assert b == 2
|
|
898
|
+
|
|
899
|
+
assert isinstance(perpetual, Perpetual)
|
|
900
|
+
|
|
901
|
+
assert perpetual.every == timedelta(milliseconds=50)
|
|
902
|
+
|
|
903
|
+
nonlocal calls
|
|
904
|
+
calls += 1
|
|
905
|
+
|
|
906
|
+
if calls == 3:
|
|
907
|
+
perpetual.cancel()
|
|
908
|
+
|
|
909
|
+
await docket.add(perpetual_task)(a="a", b=2)
|
|
910
|
+
|
|
911
|
+
await worker.run_until_finished()
|
|
912
|
+
|
|
913
|
+
assert calls == 3
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
async def test_perpetual_tasks_can_change_their_parameters(
|
|
917
|
+
docket: Docket, worker: Worker
|
|
918
|
+
):
|
|
919
|
+
"""Perpetual tasks may change their parameters each time"""
|
|
920
|
+
arguments: list[tuple[str, int]] = []
|
|
921
|
+
|
|
922
|
+
async def perpetual_task(
|
|
923
|
+
a: str,
|
|
924
|
+
b: int,
|
|
925
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
926
|
+
):
|
|
927
|
+
arguments.append((a, b))
|
|
928
|
+
perpetual.perpetuate(a + "a", b=b + 1)
|
|
929
|
+
|
|
930
|
+
execution = await docket.add(perpetual_task)(a="a", b=1)
|
|
931
|
+
|
|
932
|
+
await worker.run_at_most({execution.key: 3})
|
|
933
|
+
|
|
934
|
+
assert len(arguments) == 3
|
|
935
|
+
assert arguments == [("a", 1), ("aa", 2), ("aaa", 3)]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
async def test_perpetual_tasks_perpetuate_even_after_errors(
|
|
939
|
+
docket: Docket, worker: Worker
|
|
940
|
+
):
|
|
941
|
+
"""Perpetual tasks may change their parameters each time"""
|
|
942
|
+
calls = 0
|
|
943
|
+
|
|
944
|
+
async def perpetual_task(
|
|
945
|
+
a: str,
|
|
946
|
+
b: int,
|
|
947
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
948
|
+
):
|
|
949
|
+
nonlocal calls
|
|
950
|
+
calls += 1
|
|
951
|
+
|
|
952
|
+
raise ValueError("woops!")
|
|
953
|
+
|
|
954
|
+
execution = await docket.add(perpetual_task)(a="a", b=1)
|
|
955
|
+
|
|
956
|
+
await worker.run_at_most({execution.key: 3})
|
|
957
|
+
|
|
958
|
+
assert calls == 3
|
|
@@ -11,6 +11,7 @@ import redis.exceptions
|
|
|
11
11
|
from redis.asyncio import Redis
|
|
12
12
|
|
|
13
13
|
from docket import CurrentWorker, Docket, Worker
|
|
14
|
+
from docket.dependencies import CurrentDocket, Perpetual
|
|
14
15
|
from docket.docket import RedisMessage
|
|
15
16
|
from docket.tasks import standard_tasks
|
|
16
17
|
|
|
@@ -358,3 +359,80 @@ async def test_worker_recovers_from_redis_errors(
|
|
|
358
359
|
assert worker_info.last_seen > error_time, (
|
|
359
360
|
"Worker should have sent heartbeats after the Redis error"
|
|
360
361
|
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def test_perpetual_tasks_are_scheduled_close_to_target_time(
|
|
365
|
+
docket: Docket, worker: Worker
|
|
366
|
+
):
|
|
367
|
+
"""A perpetual task is scheduled as close to the target period as possible"""
|
|
368
|
+
timestamps: list[datetime] = []
|
|
369
|
+
|
|
370
|
+
async def perpetual_task(
|
|
371
|
+
a: str,
|
|
372
|
+
b: int,
|
|
373
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
374
|
+
):
|
|
375
|
+
timestamps.append(datetime.now(timezone.utc))
|
|
376
|
+
|
|
377
|
+
if len(timestamps) % 2 == 0:
|
|
378
|
+
await asyncio.sleep(0.05)
|
|
379
|
+
|
|
380
|
+
await docket.add(perpetual_task, key="my-key")(a="a", b=2)
|
|
381
|
+
|
|
382
|
+
await worker.run_at_most({"my-key": 8})
|
|
383
|
+
|
|
384
|
+
assert len(timestamps) == 8
|
|
385
|
+
|
|
386
|
+
intervals = [next - previous for previous, next in zip(timestamps, timestamps[1:])]
|
|
387
|
+
total = timedelta(seconds=sum(i.total_seconds() for i in intervals))
|
|
388
|
+
average = total / len(intervals)
|
|
389
|
+
|
|
390
|
+
# even with a variable duration, Docket attempts to schedule them equally
|
|
391
|
+
assert timedelta(milliseconds=45) <= average <= timedelta(milliseconds=70)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
|
|
395
|
+
docket: Docket, worker: Worker
|
|
396
|
+
):
|
|
397
|
+
"""A worker can exit if it's processing a perpetual task that queues more tasks"""
|
|
398
|
+
|
|
399
|
+
inner_calls = 0
|
|
400
|
+
|
|
401
|
+
async def inner_task():
|
|
402
|
+
nonlocal inner_calls
|
|
403
|
+
inner_calls += 1
|
|
404
|
+
|
|
405
|
+
async def perpetual_task(
|
|
406
|
+
docket: Docket = CurrentDocket(),
|
|
407
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
408
|
+
):
|
|
409
|
+
await docket.add(inner_task)()
|
|
410
|
+
await docket.add(inner_task)()
|
|
411
|
+
|
|
412
|
+
execution = await docket.add(perpetual_task)()
|
|
413
|
+
|
|
414
|
+
await worker.run_at_most({execution.key: 3})
|
|
415
|
+
|
|
416
|
+
assert inner_calls == 6
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def test_worker_can_exit_from_long_horizon_perpetual_tasks(
|
|
420
|
+
docket: Docket, worker: Worker
|
|
421
|
+
):
|
|
422
|
+
"""A worker can exit in a timely manner from a perpetual task that has a long
|
|
423
|
+
horizon because it is stricken on both execution and rescheduling"""
|
|
424
|
+
calls: int = 0
|
|
425
|
+
|
|
426
|
+
async def perpetual_task(
|
|
427
|
+
a: str,
|
|
428
|
+
b: int,
|
|
429
|
+
perpetual: Perpetual = Perpetual(every=timedelta(weeks=37)),
|
|
430
|
+
):
|
|
431
|
+
nonlocal calls
|
|
432
|
+
calls += 1
|
|
433
|
+
|
|
434
|
+
await docket.add(perpetual_task, key="my-key")(a="a", b=2)
|
|
435
|
+
|
|
436
|
+
await worker.run_at_most({"my-key": 1})
|
|
437
|
+
|
|
438
|
+
assert calls == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|