threadmill 0.3.1__tar.gz → 0.4.0__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.
- {threadmill-0.3.1 → threadmill-0.4.0}/PKG-INFO +78 -12
- {threadmill-0.3.1 → threadmill-0.4.0}/README.md +76 -7
- {threadmill-0.3.1 → threadmill-0.4.0}/pyproject.toml +2 -5
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/_version.py +3 -3
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/base.py +70 -42
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/redis.py +29 -2
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/exceptions.py +0 -2
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/executor.py +17 -4
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/app.py +0 -2
- threadmill-0.4.0/threadmill/retry.py +71 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/LICENSE +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/acknowledge.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/acquire.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/inspector.scss +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/commands/threadmill.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadmill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: The most reliable backend for Django's task framework.
|
|
5
5
|
Keywords: Django,tasks,worker
|
|
6
6
|
Author-email: Johannes Maron <johannes@maron.family>
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
Classifier: Development Status :: 4 - Beta
|
|
10
10
|
Classifier: Programming Language :: Python
|
|
@@ -20,11 +20,8 @@ Classifier: Topic :: Software Development
|
|
|
20
20
|
Classifier: Programming Language :: Python
|
|
21
21
|
Classifier: Programming Language :: Python :: 3
|
|
22
22
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
25
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
26
24
|
Classifier: Framework :: Django
|
|
27
|
-
Classifier: Framework :: Django :: 6.0
|
|
28
25
|
Classifier: Framework :: Django :: 6.1
|
|
29
26
|
License-File: LICENSE
|
|
30
27
|
Requires-Dist: django>=6.0
|
|
@@ -59,13 +56,9 @@ Provides-Extra: redis
|
|
|
59
56
|
|
|
60
57
|
## Design Principles
|
|
61
58
|
|
|
62
|
-
- **Durability** –
|
|
63
|
-
- **Consistency** –
|
|
64
|
-
- **Utilization** –
|
|
65
|
-
|
|
66
|
-
## Sponsors
|
|
67
|
-
|
|
68
|
-
[](https://github.com/sponsors/codingjoe)
|
|
59
|
+
- **Durability** – Recover from any failures, even poorly written tasks.
|
|
60
|
+
- **Consistency** – Never lose data, even if someone unplugs the power or network.
|
|
61
|
+
- **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
|
|
69
62
|
|
|
70
63
|
## Setup
|
|
71
64
|
|
|
@@ -176,5 +169,78 @@ every multi-key operation — including the cross-queue acquire — runs on a si
|
|
|
176
169
|
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
177
170
|
on cross-slot operations.
|
|
178
171
|
|
|
172
|
+
### Retrying failed tasks
|
|
173
|
+
|
|
174
|
+
Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
|
|
175
|
+
The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
176
|
+
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
177
|
+
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
178
|
+
|
|
179
|
+
The worker re-queues the failed task, preserving its ID and error history;
|
|
180
|
+
the broker promotes it back to the ready queue once the delay elapses.
|
|
181
|
+
|
|
182
|
+
#### Built-in `ExponentialBackoff`
|
|
183
|
+
|
|
184
|
+
`threadmill.retry.ExponentialBackoff` provides a serializable exponential
|
|
185
|
+
backoff strategy out of the box. It caps the delay at `max_delay`, stops
|
|
186
|
+
after `max_retries` attempts, and only retries exceptions listed in
|
|
187
|
+
`expected_exceptions`.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
import datetime
|
|
191
|
+
|
|
192
|
+
from django.tasks import task
|
|
193
|
+
from requests import HTTPError
|
|
194
|
+
|
|
195
|
+
from threadmill.retry import ExponentialBackoff
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@task(
|
|
199
|
+
retry=ExponentialBackoff(
|
|
200
|
+
base_delay=datetime.timedelta(seconds=1),
|
|
201
|
+
max_delay=datetime.timedelta(minutes=5),
|
|
202
|
+
factor=2.0,
|
|
203
|
+
max_retries=5,
|
|
204
|
+
expected_exceptions=(HTTPError,),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
def fetch_github_api(url: str): ...
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Custom retry callbacks
|
|
211
|
+
|
|
212
|
+
For cases that need logic beyond what `ExponentialBackoff` supports,
|
|
213
|
+
write a callable that accepts a `TaskContext` and returns a `timedelta`
|
|
214
|
+
or `None`. Use `TaskError.exception_class` to filter by exception type:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
import datetime
|
|
218
|
+
|
|
219
|
+
from django.tasks import task
|
|
220
|
+
from django.tasks.base import TaskContext
|
|
221
|
+
from requests import HTTPError
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
|
|
225
|
+
"""Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
|
|
226
|
+
if context.attempt >= 5:
|
|
227
|
+
return None
|
|
228
|
+
error = context.task_result.errors[-1]
|
|
229
|
+
if not issubclass(error.exception_class, HTTPError):
|
|
230
|
+
return None
|
|
231
|
+
return min(
|
|
232
|
+
datetime.timedelta(seconds=2**context.attempt),
|
|
233
|
+
datetime.timedelta(seconds=60),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@task(retry=retry_on_rate_limit)
|
|
238
|
+
def fetch_github_api(url: str): ...
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Sponsors
|
|
242
|
+
|
|
243
|
+
[](https://github.com/sponsors/codingjoe)
|
|
244
|
+
|
|
179
245
|
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
180
246
|
|
|
@@ -17,13 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
## Design Principles
|
|
19
19
|
|
|
20
|
-
- **Durability** –
|
|
21
|
-
- **Consistency** –
|
|
22
|
-
- **Utilization** –
|
|
23
|
-
|
|
24
|
-
## Sponsors
|
|
25
|
-
|
|
26
|
-
[](https://github.com/sponsors/codingjoe)
|
|
20
|
+
- **Durability** – Recover from any failures, even poorly written tasks.
|
|
21
|
+
- **Consistency** – Never lose data, even if someone unplugs the power or network.
|
|
22
|
+
- **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
|
|
27
23
|
|
|
28
24
|
## Setup
|
|
29
25
|
|
|
@@ -134,4 +130,77 @@ every multi-key operation — including the cross-queue acquire — runs on a si
|
|
|
134
130
|
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
135
131
|
on cross-slot operations.
|
|
136
132
|
|
|
133
|
+
### Retrying failed tasks
|
|
134
|
+
|
|
135
|
+
Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
|
|
136
|
+
The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
137
|
+
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
138
|
+
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
139
|
+
|
|
140
|
+
The worker re-queues the failed task, preserving its ID and error history;
|
|
141
|
+
the broker promotes it back to the ready queue once the delay elapses.
|
|
142
|
+
|
|
143
|
+
#### Built-in `ExponentialBackoff`
|
|
144
|
+
|
|
145
|
+
`threadmill.retry.ExponentialBackoff` provides a serializable exponential
|
|
146
|
+
backoff strategy out of the box. It caps the delay at `max_delay`, stops
|
|
147
|
+
after `max_retries` attempts, and only retries exceptions listed in
|
|
148
|
+
`expected_exceptions`.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import datetime
|
|
152
|
+
|
|
153
|
+
from django.tasks import task
|
|
154
|
+
from requests import HTTPError
|
|
155
|
+
|
|
156
|
+
from threadmill.retry import ExponentialBackoff
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@task(
|
|
160
|
+
retry=ExponentialBackoff(
|
|
161
|
+
base_delay=datetime.timedelta(seconds=1),
|
|
162
|
+
max_delay=datetime.timedelta(minutes=5),
|
|
163
|
+
factor=2.0,
|
|
164
|
+
max_retries=5,
|
|
165
|
+
expected_exceptions=(HTTPError,),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
def fetch_github_api(url: str): ...
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### Custom retry callbacks
|
|
172
|
+
|
|
173
|
+
For cases that need logic beyond what `ExponentialBackoff` supports,
|
|
174
|
+
write a callable that accepts a `TaskContext` and returns a `timedelta`
|
|
175
|
+
or `None`. Use `TaskError.exception_class` to filter by exception type:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import datetime
|
|
179
|
+
|
|
180
|
+
from django.tasks import task
|
|
181
|
+
from django.tasks.base import TaskContext
|
|
182
|
+
from requests import HTTPError
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
|
|
186
|
+
"""Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
|
|
187
|
+
if context.attempt >= 5:
|
|
188
|
+
return None
|
|
189
|
+
error = context.task_result.errors[-1]
|
|
190
|
+
if not issubclass(error.exception_class, HTTPError):
|
|
191
|
+
return None
|
|
192
|
+
return min(
|
|
193
|
+
datetime.timedelta(seconds=2**context.attempt),
|
|
194
|
+
datetime.timedelta(seconds=60),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@task(retry=retry_on_rate_limit)
|
|
199
|
+
def fetch_github_api(url: str): ...
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Sponsors
|
|
203
|
+
|
|
204
|
+
[](https://github.com/sponsors/codingjoe)
|
|
205
|
+
|
|
137
206
|
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
@@ -26,14 +26,11 @@ classifiers = [
|
|
|
26
26
|
"Programming Language :: Python",
|
|
27
27
|
"Programming Language :: Python :: 3",
|
|
28
28
|
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
-
"Programming Language :: Python :: 3.12",
|
|
30
|
-
"Programming Language :: Python :: 3.13",
|
|
31
29
|
"Programming Language :: Python :: 3.14",
|
|
32
30
|
"Framework :: Django",
|
|
33
|
-
"Framework :: Django :: 6.0",
|
|
34
31
|
"Framework :: Django :: 6.1",
|
|
35
32
|
]
|
|
36
|
-
requires-python = ">=3.
|
|
33
|
+
requires-python = ">=3.14"
|
|
37
34
|
dependencies = ["django>=6.0"]
|
|
38
35
|
|
|
39
36
|
[project.optional-dependencies]
|
|
@@ -59,7 +56,7 @@ name = "threadmill"
|
|
|
59
56
|
write_to = "threadmill/_version.py"
|
|
60
57
|
|
|
61
58
|
[tool.pytest.ini_options]
|
|
62
|
-
minversion = "6.
|
|
59
|
+
minversion = "6.1a1"
|
|
63
60
|
addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
|
|
64
61
|
testpaths = ["tests"]
|
|
65
62
|
DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gf9749f870'
|
|
@@ -1,39 +1,63 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import collections.abc
|
|
4
2
|
import dataclasses
|
|
5
3
|
import datetime
|
|
6
4
|
import json
|
|
7
5
|
import threading
|
|
6
|
+
import typing
|
|
8
7
|
from abc import ABC
|
|
9
8
|
|
|
10
|
-
import django
|
|
11
9
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
12
10
|
from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
|
|
13
11
|
from django.tasks.backends.base import BaseTaskBackend
|
|
14
|
-
from django.tasks.base import TaskError
|
|
12
|
+
from django.tasks.base import TaskContext, TaskError
|
|
13
|
+
from django.tasks.exceptions import InvalidTask
|
|
14
|
+
from django.utils.inspect import is_module_level_function
|
|
15
15
|
from django.utils.module_loading import import_string
|
|
16
16
|
|
|
17
|
-
if django.VERSION == (6, 0):
|
|
18
|
-
# https://github.com/django/django/commit/8c8b833d32c02d3ae6f43b04bb1e45968796b402
|
|
19
|
-
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
20
|
-
class Task(Task):
|
|
21
|
-
@classmethod
|
|
22
|
-
def _reconstruct(cls, kwargs):
|
|
23
|
-
func_path = kwargs["func"]
|
|
24
|
-
try:
|
|
25
|
-
func = import_string(func_path)
|
|
26
|
-
kwargs["func"] = func.func
|
|
27
|
-
except (ImportError, AttributeError) as e:
|
|
28
|
-
msg = f"Expected {func_path!r} to point to a Task instance."
|
|
29
|
-
raise ValueError(msg) from e
|
|
30
|
-
return cls(**kwargs)
|
|
31
17
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
18
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
19
|
+
class RetryTask(Task):
|
|
20
|
+
"""Task with an optional retry callback for backoff scheduling.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
|
|
24
|
+
def retry_callback(context: TaskContext) -> datetime.timedelta | None:
|
|
25
|
+
if context.attempt < 3:
|
|
26
|
+
return datetime.timedelta(seconds=60 * (2 ** context.attempt))
|
|
35
27
|
|
|
36
|
-
|
|
28
|
+
@task(retry=retry_callback)
|
|
29
|
+
def my_task():
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
retry: collections.abc.Callable[[TaskContext], datetime.timedelta | None] | None = (
|
|
35
|
+
None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def retry_path(self) -> tuple[str, tuple, dict[str, typing.Any]] | str | None:
|
|
40
|
+
"""Importable dotted path of the retry callback, or None."""
|
|
41
|
+
if self.retry:
|
|
42
|
+
if hasattr(self.retry, "deconstruct"):
|
|
43
|
+
return self.retry.deconstruct()
|
|
44
|
+
return f"{self.retry.__module__}.{self.retry.__qualname__}"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def _reconstruct(cls, kwargs):
|
|
48
|
+
if retry_path := kwargs.pop("retry", None):
|
|
49
|
+
try:
|
|
50
|
+
path, pos_args, kw_args = retry_path
|
|
51
|
+
except ValueError:
|
|
52
|
+
kwargs["retry"] = import_string(retry_path)
|
|
53
|
+
else:
|
|
54
|
+
kwargs["retry"] = import_string(path)(*pos_args, **kw_args)
|
|
55
|
+
return super()._reconstruct(kwargs)
|
|
56
|
+
|
|
57
|
+
def __reduce__(self):
|
|
58
|
+
reconstructor, (kwargs,) = super().__reduce__()
|
|
59
|
+
kwargs["retry"] = self.retry_path
|
|
60
|
+
return (reconstructor, (kwargs,))
|
|
37
61
|
|
|
38
62
|
|
|
39
63
|
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
@@ -116,19 +140,20 @@ class TaskResultEncoder(DjangoJSONEncoder):
|
|
|
116
140
|
field.name: getattr(o, field.name)
|
|
117
141
|
for field in dataclasses.fields(type(o))
|
|
118
142
|
}
|
|
119
|
-
if isinstance(o,
|
|
120
|
-
|
|
143
|
+
if isinstance(o, RetryTask):
|
|
144
|
+
data = {
|
|
121
145
|
field.name: getattr(o, field.name)
|
|
122
|
-
for field in dataclasses.fields(
|
|
123
|
-
if field.name
|
|
124
|
-
} | {"func": o.module_path}
|
|
146
|
+
for field in dataclasses.fields(RetryTask)
|
|
147
|
+
if field.name not in {"func", "retry"}
|
|
148
|
+
} | {"func": o.module_path, "retry": o.retry_path}
|
|
149
|
+
return data
|
|
125
150
|
return super().default(o)
|
|
126
151
|
|
|
127
152
|
|
|
128
153
|
class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
129
154
|
"""Interface for task queues to be processed by the executor."""
|
|
130
155
|
|
|
131
|
-
task_class =
|
|
156
|
+
task_class = RetryTask
|
|
132
157
|
supports_async_task = True
|
|
133
158
|
supports_get_result = True
|
|
134
159
|
broker_class: type[Broker] | None = None
|
|
@@ -144,18 +169,8 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
144
169
|
def _object_hook(d: dict) -> dict | TaskResult:
|
|
145
170
|
if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
|
|
146
171
|
task_data = d["task"]
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
func = func.func
|
|
150
|
-
d["task"] = cls.task_class(
|
|
151
|
-
func=func,
|
|
152
|
-
**{
|
|
153
|
-
field.name: _parse_datetime(task_data[field.name])
|
|
154
|
-
for field in dataclasses.fields(cls.task_class)
|
|
155
|
-
if field.name not in {"func", "takes_context"}
|
|
156
|
-
and field.name in task_data
|
|
157
|
-
},
|
|
158
|
-
)
|
|
172
|
+
task_data["run_after"] = _parse_datetime(task_data["run_after"])
|
|
173
|
+
d["task"] = cls.task_class._reconstruct(task_data)
|
|
159
174
|
d["status"] = TaskResultStatus(d["status"])
|
|
160
175
|
d["errors"] = [TaskError(**error) for error in d["errors"]]
|
|
161
176
|
return_value = d.pop("_return_value", None)
|
|
@@ -168,6 +183,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
168
183
|
|
|
169
184
|
return json.loads(data, object_hook=_object_hook)
|
|
170
185
|
|
|
186
|
+
def validate_task(self, task: RetryTask) -> None:
|
|
187
|
+
super().validate_task(task)
|
|
188
|
+
if task.retry is not None and not (
|
|
189
|
+
is_module_level_function(task.retry) or hasattr(task.retry, "deconstruct")
|
|
190
|
+
):
|
|
191
|
+
raise InvalidTask(
|
|
192
|
+
"Task's retry function must be defined at a module level or be a deconstructible callable."
|
|
193
|
+
)
|
|
194
|
+
|
|
171
195
|
def acquire(
|
|
172
196
|
self,
|
|
173
197
|
*queue_names: str,
|
|
@@ -192,15 +216,19 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
192
216
|
"""Remove the task from the queue and publish the result."""
|
|
193
217
|
raise NotImplementedError
|
|
194
218
|
|
|
219
|
+
def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
|
|
220
|
+
"""Re-queue a failed task result for a retry attempt after `run_after`."""
|
|
221
|
+
raise NotImplementedError
|
|
222
|
+
|
|
195
223
|
def peek(
|
|
196
224
|
self,
|
|
197
225
|
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
198
226
|
*,
|
|
199
227
|
status: TaskResultStatus,
|
|
200
228
|
count: int = 1,
|
|
201
|
-
) -> collections.abc.Generator[TaskResult
|
|
229
|
+
) -> collections.abc.Generator[TaskResult]:
|
|
202
230
|
"""
|
|
203
|
-
Yield up to
|
|
231
|
+
Yield up to `count` tasks from a queue in the given status segment.
|
|
204
232
|
|
|
205
233
|
Args:
|
|
206
234
|
queue_name: The name of the queue to peek into.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Redis-backed durable priority queue backend for Django's task framework."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import dataclasses
|
|
5
4
|
import datetime
|
|
6
5
|
import logging
|
|
7
6
|
import queue
|
|
@@ -315,6 +314,34 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
315
314
|
],
|
|
316
315
|
)
|
|
317
316
|
|
|
317
|
+
def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
|
|
318
|
+
task_result = dataclasses.replace(
|
|
319
|
+
task_result,
|
|
320
|
+
status=TaskResultStatus.READY,
|
|
321
|
+
started_at=None,
|
|
322
|
+
finished_at=None,
|
|
323
|
+
)
|
|
324
|
+
serialized = self.serialize_task_result(task_result)
|
|
325
|
+
running_key = self.RUNNING_KEY.format(
|
|
326
|
+
prefix=self.key_prefix, queue_name=task_result.task.queue_name
|
|
327
|
+
)
|
|
328
|
+
deferred_key = self.DEFERRED_KEY.format(
|
|
329
|
+
prefix=self.key_prefix, queue_name=task_result.task.queue_name
|
|
330
|
+
)
|
|
331
|
+
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
332
|
+
score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
|
|
333
|
+
run_after_ms = run_after.timestamp() * 1000
|
|
334
|
+
task_data_ttl = int(
|
|
335
|
+
self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
pipe = self.client.pipeline()
|
|
339
|
+
pipe.zrem(running_key, task_result.id)
|
|
340
|
+
pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
|
|
341
|
+
pipe.expire(task_key, task_data_ttl)
|
|
342
|
+
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
343
|
+
pipe.execute()
|
|
344
|
+
|
|
318
345
|
def peek(
|
|
319
346
|
self,
|
|
320
347
|
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Task worker executor implementation."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import asyncio
|
|
6
4
|
import dataclasses
|
|
7
5
|
import datetime
|
|
@@ -205,17 +203,32 @@ class WorkerThread(threading.Thread):
|
|
|
205
203
|
timeout=datetime.timedelta(seconds=1),
|
|
206
204
|
worker=self.name,
|
|
207
205
|
)
|
|
208
|
-
except
|
|
206
|
+
except Empty, TimeoutError:
|
|
209
207
|
if self.worker.shutdown_requested.is_set() or self.worker.exit_empty:
|
|
210
208
|
return
|
|
211
209
|
continue
|
|
212
210
|
|
|
213
211
|
try:
|
|
214
212
|
result = self.execute_task_result(task_result)
|
|
215
|
-
|
|
213
|
+
if (
|
|
214
|
+
result.status is TaskResultStatus.FAILED
|
|
215
|
+
and (delay := self.retry_delay(result)) is not None
|
|
216
|
+
):
|
|
217
|
+
self.backend.requeue(result, timezone.now() + delay)
|
|
218
|
+
else:
|
|
219
|
+
self.backend.acknowledge(result)
|
|
216
220
|
finally:
|
|
217
221
|
self.worker.record_task()
|
|
218
222
|
|
|
223
|
+
@staticmethod
|
|
224
|
+
def retry_delay(task_result: TaskResult) -> datetime.timedelta | None:
|
|
225
|
+
"""Return the retry delay for a failed task, or None to stop retrying."""
|
|
226
|
+
if task_result.task.retry:
|
|
227
|
+
try:
|
|
228
|
+
return task_result.task.retry(TaskContext(task_result=task_result))
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.exception("Retry callback failed for task %r", task_result.id)
|
|
231
|
+
|
|
219
232
|
def execute_task_result(self, task_result: TaskResult) -> TaskResult:
|
|
220
233
|
"""Execute task from task result and update result lifecycle state."""
|
|
221
234
|
logger.info("Executing task %r", task_result.id)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Reusable callables for retrying tasks."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from django.tasks import TaskContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
12
|
+
class ExponentialBackoff:
|
|
13
|
+
"""Delay retries exponentially, capped at ``max_delay`` and limited to ``max_retries`` attempts.
|
|
14
|
+
|
|
15
|
+
Only retry exceptions listed in ``expected_exceptions``.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
|
|
19
|
+
@task(retry=ExponentialBackoff(base_delay=datetime.timedelta(seconds=1), max_delay=datetime.timedelta(minutes=5), factor=2.0, max_retries=5))
|
|
20
|
+
def my_task():
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
base_delay: datetime.timedelta = dataclasses.field(
|
|
25
|
+
default=datetime.timedelta(seconds=1), doc="Base delay in seconds"
|
|
26
|
+
)
|
|
27
|
+
max_delay: datetime.timedelta = dataclasses.field(
|
|
28
|
+
default=datetime.timedelta(minutes=60), doc="Maximum delay in seconds"
|
|
29
|
+
)
|
|
30
|
+
factor: float = dataclasses.field(default=2.0, doc="Exponential factor for backoff")
|
|
31
|
+
max_retries: int = dataclasses.field(default=5, doc="Maximum number of retries")
|
|
32
|
+
expected_exceptions: tuple[type[Exception], ...] = dataclasses.field(
|
|
33
|
+
default=(Exception,), doc="Tuple of exception classes that trigger a retry"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
"""Resolve deserialized fields back to their original types."""
|
|
38
|
+
for field_name in ("base_delay", "max_delay"):
|
|
39
|
+
value = getattr(self, field_name)
|
|
40
|
+
if isinstance(value, (int, float)):
|
|
41
|
+
object.__setattr__(self, field_name, datetime.timedelta(seconds=value))
|
|
42
|
+
resolved = []
|
|
43
|
+
for exc in self.expected_exceptions:
|
|
44
|
+
if isinstance(exc, str):
|
|
45
|
+
from django.utils.module_loading import import_string
|
|
46
|
+
|
|
47
|
+
exc = import_string(exc)
|
|
48
|
+
resolved.append(exc)
|
|
49
|
+
object.__setattr__(self, "expected_exceptions", tuple(resolved))
|
|
50
|
+
|
|
51
|
+
def __call__(self, context: TaskContext) -> datetime.timedelta | None:
|
|
52
|
+
if context.attempt < self.max_retries and issubclass(
|
|
53
|
+
context.task_result.errors[-1].exception_class, self.expected_exceptions
|
|
54
|
+
):
|
|
55
|
+
return min(self.base_delay * (self.factor**context.attempt), self.max_delay)
|
|
56
|
+
|
|
57
|
+
def deconstruct(self):
|
|
58
|
+
return (
|
|
59
|
+
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
|
|
60
|
+
(),
|
|
61
|
+
{
|
|
62
|
+
"base_delay": self.base_delay.total_seconds(),
|
|
63
|
+
"max_delay": self.max_delay.total_seconds(),
|
|
64
|
+
"factor": self.factor,
|
|
65
|
+
"max_retries": self.max_retries,
|
|
66
|
+
"expected_exceptions": tuple(
|
|
67
|
+
f"{exc.__module__}.{exc.__qualname__}"
|
|
68
|
+
for exc in self.expected_exceptions
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
)
|
|
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
|