threadmill 0.3.1__tar.gz → 0.5.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.5.0}/PKG-INFO +78 -12
- {threadmill-0.3.1 → threadmill-0.5.0}/README.md +76 -7
- {threadmill-0.3.1 → threadmill-0.5.0}/pyproject.toml +2 -5
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/_version.py +3 -3
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/base.py +87 -42
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/acquire.lua +1 -1
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/redis.py +95 -65
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/exceptions.py +0 -2
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/executor.py +17 -4
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/app.py +102 -9
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/inspector.scss +31 -0
- threadmill-0.5.0/threadmill/inspector/screens.py +52 -0
- threadmill-0.5.0/threadmill/retry.py +71 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/LICENSE +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/acknowledge.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/management/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.5.0}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.3.1 → threadmill-0.5.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.5.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.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g190acbe27'
|
|
@@ -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))
|
|
27
|
+
|
|
28
|
+
@task(retry=retry_callback)
|
|
29
|
+
def my_task():
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
"""
|
|
35
33
|
|
|
36
|
-
|
|
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,24 @@ 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
|
+
|
|
222
|
+
Cleans up any persisted failed result so the method works both for
|
|
223
|
+
in-flight retries (task still running) and inspector-driven requeues
|
|
224
|
+
of already-failed tasks.
|
|
225
|
+
"""
|
|
226
|
+
raise NotImplementedError
|
|
227
|
+
|
|
195
228
|
def peek(
|
|
196
229
|
self,
|
|
197
230
|
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
198
231
|
*,
|
|
199
232
|
status: TaskResultStatus,
|
|
200
233
|
count: int = 1,
|
|
201
|
-
) -> collections.abc.Generator[TaskResult
|
|
234
|
+
) -> collections.abc.Generator[TaskResult]:
|
|
202
235
|
"""
|
|
203
|
-
Yield up to
|
|
236
|
+
Yield up to `count` tasks from a queue in the given status segment.
|
|
204
237
|
|
|
205
238
|
Args:
|
|
206
239
|
queue_name: The name of the queue to peek into.
|
|
@@ -218,3 +251,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
218
251
|
interval: The time window for rolling rates.
|
|
219
252
|
"""
|
|
220
253
|
raise NotImplementedError
|
|
254
|
+
|
|
255
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
256
|
+
"""Delete a single task from its current status segment."""
|
|
257
|
+
raise NotImplementedError
|
|
258
|
+
|
|
259
|
+
def purge(self, queue_name: str) -> None:
|
|
260
|
+
"""Delete every task across all segments of a queue.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
queue_name: The queue to purge.
|
|
264
|
+
"""
|
|
265
|
+
raise NotImplementedError
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
-- KEYS[4] = queue, etc.
|
|
8
8
|
-- ARGV[1] -- current time in milliseconds
|
|
9
9
|
-- ARGV[2] -- current time as ISO-8601 string
|
|
10
|
-
-- ARGV[3] -- task key prefix (e.g. "threadmill:
|
|
10
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:task:")
|
|
11
11
|
-- ARGV[4] -- number of queue pairs (N/2)
|
|
12
12
|
-- ARGV[5] -- worker name
|
|
13
13
|
-- ARGV[6] -- lease TTL in milliseconds
|
|
@@ -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
|
|
@@ -56,14 +55,12 @@ class RedisBroker(Broker):
|
|
|
56
55
|
deferred_key = self.backend.DEFERRED_KEY.format(
|
|
57
56
|
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
58
57
|
)
|
|
59
|
-
queue_key = self.backend.
|
|
60
|
-
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
61
|
-
)
|
|
58
|
+
queue_key = self.backend._segment_key(TaskResultStatus.READY, queue_name)
|
|
62
59
|
self._mover_script(
|
|
63
60
|
keys=[deferred_key, queue_key],
|
|
64
61
|
args=[
|
|
65
62
|
str(time.time() * 1000),
|
|
66
|
-
self.backend.key_prefix
|
|
63
|
+
f"{self.backend.key_prefix}:task:",
|
|
67
64
|
str(self.backend.batch_size),
|
|
68
65
|
],
|
|
69
66
|
)
|
|
@@ -73,18 +70,16 @@ class RedisBroker(Broker):
|
|
|
73
70
|
now = timezone.now()
|
|
74
71
|
now_ms = now.timestamp() * 1000
|
|
75
72
|
finished_at_iso = now.isoformat()
|
|
76
|
-
running_key = self.backend.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
failed_results_key = self.backend.FAILED_RESULTS_KEY.format(
|
|
80
|
-
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
73
|
+
running_key = self.backend._segment_key(TaskResultStatus.RUNNING, queue_name)
|
|
74
|
+
failed_results_key = self.backend._segment_key(
|
|
75
|
+
TaskResultStatus.FAILED, queue_name
|
|
81
76
|
)
|
|
82
77
|
self._reaper_script(
|
|
83
78
|
keys=[running_key, failed_results_key],
|
|
84
79
|
args=[
|
|
85
80
|
str(now_ms),
|
|
86
|
-
self.backend.key_prefix
|
|
87
|
-
self.backend.key_prefix
|
|
81
|
+
f"{self.backend.key_prefix}:task:",
|
|
82
|
+
f"{self.backend.key_prefix}:result:",
|
|
88
83
|
str(self.backend.batch_size),
|
|
89
84
|
str(int(self.backend.result_ttl.total_seconds())),
|
|
90
85
|
finished_at_iso,
|
|
@@ -126,20 +121,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
126
121
|
|
|
127
122
|
broker_class = RedisBroker
|
|
128
123
|
|
|
129
|
-
QUEUE_KEY = "{prefix}:queue:{queue_name}"
|
|
130
|
-
RUNNING_KEY = "{prefix}:running:{queue_name}"
|
|
131
|
-
DEFERRED_KEY = "{prefix}:deferred:{queue_name}"
|
|
132
124
|
TASK_KEY = "{prefix}:task:{task_id}"
|
|
133
125
|
RESULT_KEY = "{prefix}:result:{result_id}"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
|
|
127
|
+
DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
|
|
128
|
+
|
|
129
|
+
INGRESS_KEY = "{prefix}:{queue_name}:ingress:events"
|
|
137
130
|
|
|
138
131
|
ACQUIRE_SCRIPT = _load_lua("acquire")
|
|
139
132
|
"""Pop the next task from a priority queue and move it directly to the running set."""
|
|
140
133
|
ACKNOWLEDGE_SCRIPT = _load_lua("acknowledge")
|
|
141
134
|
"""Remove from running, persist the result, and clean up."""
|
|
142
135
|
|
|
136
|
+
def _segment_key(self, status: TaskResultStatus, queue_name: str) -> str:
|
|
137
|
+
return self.SEGMENT_KEY.format(
|
|
138
|
+
prefix=self.key_prefix,
|
|
139
|
+
queue_name=queue_name,
|
|
140
|
+
status=status.value.lower(),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
143
|
def __init__(self, alias: str, params: dict) -> None:
|
|
144
144
|
super().__init__(alias=alias, params=params)
|
|
145
145
|
|
|
@@ -225,9 +225,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
225
225
|
run_after_ms = task.run_after.timestamp() * 1000
|
|
226
226
|
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
227
227
|
else:
|
|
228
|
-
queue_key = self.
|
|
229
|
-
prefix=self.key_prefix, queue_name=task.queue_name
|
|
230
|
-
)
|
|
228
|
+
queue_key = self._segment_key(TaskResultStatus.READY, task.queue_name)
|
|
231
229
|
pipe.zadd(queue_key, {task_result.id: score})
|
|
232
230
|
|
|
233
231
|
pipe.execute()
|
|
@@ -247,8 +245,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
247
245
|
key
|
|
248
246
|
for queue_name in queue_names
|
|
249
247
|
for key in (
|
|
250
|
-
self.
|
|
251
|
-
self.
|
|
248
|
+
self._segment_key(TaskResultStatus.RUNNING, queue_name),
|
|
249
|
+
self._segment_key(TaskResultStatus.READY, queue_name),
|
|
252
250
|
)
|
|
253
251
|
]
|
|
254
252
|
|
|
@@ -262,7 +260,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
262
260
|
args=[
|
|
263
261
|
str(now_ms),
|
|
264
262
|
now_iso,
|
|
265
|
-
self.key_prefix
|
|
263
|
+
f"{self.key_prefix}:task:",
|
|
266
264
|
str(len(queue_names)),
|
|
267
265
|
worker,
|
|
268
266
|
str(int(self.lease_ttl.total_seconds() * 1000)),
|
|
@@ -282,18 +280,18 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
282
280
|
|
|
283
281
|
def acknowledge(self, task_result: TaskResult) -> None:
|
|
284
282
|
serialized = self.serialize_task_result(task_result)
|
|
285
|
-
running_key = self.
|
|
286
|
-
|
|
283
|
+
running_key = self._segment_key(
|
|
284
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
287
285
|
)
|
|
288
286
|
result_key = self.RESULT_KEY.format(
|
|
289
287
|
prefix=self.key_prefix, result_id=task_result.id
|
|
290
288
|
)
|
|
291
289
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
292
|
-
successful_results_key = self.
|
|
293
|
-
|
|
290
|
+
successful_results_key = self._segment_key(
|
|
291
|
+
TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
|
|
294
292
|
)
|
|
295
|
-
failed_results_key = self.
|
|
296
|
-
|
|
293
|
+
failed_results_key = self._segment_key(
|
|
294
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
297
295
|
)
|
|
298
296
|
finished_at = task_result.finished_at or timezone.now()
|
|
299
297
|
finish_score = finished_at.timestamp() * 1000
|
|
@@ -315,6 +313,55 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
315
313
|
],
|
|
316
314
|
)
|
|
317
315
|
|
|
316
|
+
def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
|
|
317
|
+
task_result = dataclasses.replace(
|
|
318
|
+
task_result,
|
|
319
|
+
status=TaskResultStatus.READY,
|
|
320
|
+
started_at=None,
|
|
321
|
+
finished_at=None,
|
|
322
|
+
)
|
|
323
|
+
serialized = self.serialize_task_result(task_result)
|
|
324
|
+
running_key = self._segment_key(
|
|
325
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
326
|
+
)
|
|
327
|
+
deferred_key = self.DEFERRED_KEY.format(
|
|
328
|
+
prefix=self.key_prefix, queue_name=task_result.task.queue_name
|
|
329
|
+
)
|
|
330
|
+
failed_key = self._segment_key(
|
|
331
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
332
|
+
)
|
|
333
|
+
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
334
|
+
result_key = self.RESULT_KEY.format(
|
|
335
|
+
prefix=self.key_prefix, result_id=task_result.id
|
|
336
|
+
)
|
|
337
|
+
score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
|
|
338
|
+
run_after_ms = run_after.timestamp() * 1000
|
|
339
|
+
task_data_ttl = int(
|
|
340
|
+
self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
pipe = self.client.pipeline()
|
|
344
|
+
pipe.zrem(running_key, task_result.id)
|
|
345
|
+
pipe.zrem(failed_key, task_result.id)
|
|
346
|
+
pipe.delete(result_key)
|
|
347
|
+
pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
|
|
348
|
+
pipe.expire(task_key, task_data_ttl)
|
|
349
|
+
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
350
|
+
pipe.execute()
|
|
351
|
+
|
|
352
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
353
|
+
self.client.zrem(
|
|
354
|
+
self._segment_key(task_result.status, task_result.task.queue_name),
|
|
355
|
+
task_result.id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def purge(self, queue_name: str) -> None:
|
|
359
|
+
pattern = f"{self.key_prefix}:{queue_name}:*"
|
|
360
|
+
pipe = self.client.pipeline()
|
|
361
|
+
for key in self.client.scan_iter(match=pattern):
|
|
362
|
+
pipe.delete(key)
|
|
363
|
+
pipe.execute()
|
|
364
|
+
|
|
318
365
|
def peek(
|
|
319
366
|
self,
|
|
320
367
|
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
@@ -323,34 +370,27 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
323
370
|
count: int = 1,
|
|
324
371
|
) -> Generator[TaskResult]:
|
|
325
372
|
match status:
|
|
326
|
-
case TaskResultStatus.READY:
|
|
373
|
+
case TaskResultStatus.READY | TaskResultStatus.RUNNING:
|
|
327
374
|
yield from self._peek(
|
|
328
|
-
self.
|
|
375
|
+
self._segment_key(status, queue_name),
|
|
376
|
+
self.TASK_KEY,
|
|
377
|
+
count,
|
|
378
|
+
"data",
|
|
329
379
|
)
|
|
330
|
-
case TaskResultStatus.
|
|
380
|
+
case TaskResultStatus.SUCCESSFUL | TaskResultStatus.FAILED:
|
|
331
381
|
yield from self._peek(
|
|
332
|
-
self.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
yield from self._peek(
|
|
336
|
-
self.SUCCESSFUL_RESULTS_KEY, self.RESULT_KEY, queue_name, count
|
|
337
|
-
)
|
|
338
|
-
case TaskResultStatus.FAILED:
|
|
339
|
-
yield from self._peek(
|
|
340
|
-
self.FAILED_RESULTS_KEY, self.RESULT_KEY, queue_name, count
|
|
382
|
+
self._segment_key(status, queue_name),
|
|
383
|
+
self.RESULT_KEY,
|
|
384
|
+
count,
|
|
341
385
|
)
|
|
342
386
|
|
|
343
387
|
def _peek(
|
|
344
388
|
self,
|
|
345
|
-
|
|
389
|
+
zset_key: str,
|
|
346
390
|
data_key_template: str,
|
|
347
|
-
queue_name: str,
|
|
348
391
|
count: int,
|
|
349
392
|
field: str | None = None,
|
|
350
393
|
) -> Generator[TaskResult]:
|
|
351
|
-
zset_key = zset_key_template.format(
|
|
352
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
353
|
-
)
|
|
354
394
|
pipe = self.client.pipeline()
|
|
355
395
|
for member in self.client.zrange(zset_key, 0, count - 1):
|
|
356
396
|
member_id = member.decode() if isinstance(member, bytes) else member
|
|
@@ -385,16 +425,12 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
385
425
|
cutoff,
|
|
386
426
|
)
|
|
387
427
|
pipe.zremrangebyscore(
|
|
388
|
-
self.
|
|
389
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
390
|
-
),
|
|
428
|
+
self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name),
|
|
391
429
|
0,
|
|
392
430
|
cutoff,
|
|
393
431
|
)
|
|
394
432
|
pipe.zremrangebyscore(
|
|
395
|
-
self.
|
|
396
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
397
|
-
),
|
|
433
|
+
self._segment_key(TaskResultStatus.FAILED, queue_name),
|
|
398
434
|
0,
|
|
399
435
|
cutoff,
|
|
400
436
|
)
|
|
@@ -409,21 +445,15 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
409
445
|
retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
|
|
410
446
|
pipe = self.client.pipeline()
|
|
411
447
|
for queue_name in self.queues:
|
|
412
|
-
pipe.zcard(
|
|
413
|
-
|
|
414
|
-
)
|
|
415
|
-
pipe.zcard(
|
|
416
|
-
self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
|
|
417
|
-
)
|
|
448
|
+
pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
|
|
449
|
+
pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
|
|
418
450
|
pipe.zcard(
|
|
419
451
|
self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
|
|
420
452
|
)
|
|
421
|
-
successful_results_key = self.
|
|
422
|
-
|
|
423
|
-
)
|
|
424
|
-
failed_results_key = self.FAILED_RESULTS_KEY.format(
|
|
425
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
453
|
+
successful_results_key = self._segment_key(
|
|
454
|
+
TaskResultStatus.SUCCESSFUL, queue_name
|
|
426
455
|
)
|
|
456
|
+
failed_results_key = self._segment_key(TaskResultStatus.FAILED, queue_name)
|
|
427
457
|
pipe.zcard(successful_results_key)
|
|
428
458
|
pipe.zcard(failed_results_key)
|
|
429
459
|
ingress_key = self.INGRESS_KEY.format(
|
|
@@ -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)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Textual app for the inspector TUI."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import datetime
|
|
6
4
|
import logging
|
|
7
5
|
import math
|
|
@@ -32,6 +30,7 @@ from textual.widgets import (
|
|
|
32
30
|
)
|
|
33
31
|
|
|
34
32
|
from ..backends.base import BackendTelemetry, ThreadmillTaskBackend
|
|
33
|
+
from .screens import ConfirmScreen, PurgeScreen
|
|
35
34
|
|
|
36
35
|
logger = logging.getLogger(__name__)
|
|
37
36
|
|
|
@@ -165,6 +164,21 @@ class TaskList(Vertical):
|
|
|
165
164
|
counts: reactive[dict[str, int]] = reactive({})
|
|
166
165
|
selected_task: reactive[TaskResult | None] = reactive(None)
|
|
167
166
|
|
|
167
|
+
BINDINGS = [
|
|
168
|
+
Binding("f5", "refresh", "Refresh"),
|
|
169
|
+
Binding("r", "requeue", "Requeue"),
|
|
170
|
+
Binding("d", "dequeue", "Drop"),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
174
|
+
"""Show requeue only on the Failed tab, dequeue on non-Running tabs."""
|
|
175
|
+
status = self.active_status()
|
|
176
|
+
if action == "requeue":
|
|
177
|
+
return status == TaskResultStatus.FAILED
|
|
178
|
+
if action == "dequeue":
|
|
179
|
+
return status is not None and status != TaskResultStatus.RUNNING
|
|
180
|
+
return True
|
|
181
|
+
|
|
168
182
|
def compose(self) -> ComposeResult:
|
|
169
183
|
with TabbedContent(initial="tab-ready") as tabs:
|
|
170
184
|
for label, _ in TAB_STATUSES:
|
|
@@ -214,6 +228,11 @@ class TaskList(Vertical):
|
|
|
214
228
|
"""Activate the tab with the given id."""
|
|
215
229
|
self._tabs.active = tab_id
|
|
216
230
|
|
|
231
|
+
def action_refresh(self) -> None:
|
|
232
|
+
"""Refresh telemetry and re-fetch the task list."""
|
|
233
|
+
self.app._refresh_telemetry()
|
|
234
|
+
self.refresh_tasks()
|
|
235
|
+
|
|
217
236
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
218
237
|
"""Notify the app when a task row is selected."""
|
|
219
238
|
if isinstance(event.row_key.value, str):
|
|
@@ -229,6 +248,7 @@ class TaskList(Vertical):
|
|
|
229
248
|
) -> None:
|
|
230
249
|
"""Refresh the visible table when the user switches tabs."""
|
|
231
250
|
self._refresh_data()
|
|
251
|
+
self.refresh_bindings()
|
|
232
252
|
|
|
233
253
|
def _select_task_by_id(self, task_id: str) -> None:
|
|
234
254
|
"""Find the task result matching the row key in the current results."""
|
|
@@ -237,6 +257,57 @@ class TaskList(Vertical):
|
|
|
237
257
|
None,
|
|
238
258
|
)
|
|
239
259
|
|
|
260
|
+
def active_status(self) -> TaskResultStatus | None:
|
|
261
|
+
"""Return the status segment of the currently active task tab."""
|
|
262
|
+
tab_id = self._tabs.active.removeprefix("tab-")
|
|
263
|
+
return next(
|
|
264
|
+
(status for label, status in TAB_STATUSES if label.lower() == tab_id),
|
|
265
|
+
None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def action_requeue(self) -> None:
|
|
269
|
+
"""Requeue the selected failed task after confirmation."""
|
|
270
|
+
task = self.selected_task
|
|
271
|
+
if task and self.active_status() == TaskResultStatus.FAILED:
|
|
272
|
+
self.app.push_screen(
|
|
273
|
+
ConfirmScreen(f"Requeue task {task.id[:8]}?"),
|
|
274
|
+
lambda confirmed: self._do_requeue(confirmed, task),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _do_requeue(self, confirmed: bool, task: TaskResult) -> None:
|
|
278
|
+
if not confirmed:
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
self.backend.requeue(task, datetime.datetime.now(datetime.UTC))
|
|
282
|
+
except Exception: # noqa: BLE001
|
|
283
|
+
logger.exception("Requeue failed for task %s", task.id)
|
|
284
|
+
self.app.notify("Requeue failed.", severity="error")
|
|
285
|
+
return
|
|
286
|
+
self.app._after_action("Task requeued.")
|
|
287
|
+
|
|
288
|
+
def action_dequeue(self) -> None:
|
|
289
|
+
"""Remove the selected task from its current segment after confirmation."""
|
|
290
|
+
task = self.selected_task
|
|
291
|
+
if task and self.active_status() is not None:
|
|
292
|
+
self.app.push_screen(
|
|
293
|
+
ConfirmScreen(
|
|
294
|
+
f"Delete task {task.task.func.__name__} ({task.id[:8]})?",
|
|
295
|
+
danger=True,
|
|
296
|
+
),
|
|
297
|
+
lambda confirmed: self._do_dequeue(confirmed, task),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _do_dequeue(self, confirmed: bool, task: TaskResult) -> None:
|
|
301
|
+
if not confirmed:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
self.backend.dequeue(task)
|
|
305
|
+
except Exception: # noqa: BLE001
|
|
306
|
+
logger.exception("Delete failed for task %s", task.id)
|
|
307
|
+
self.app.notify("Delete failed.", severity="error")
|
|
308
|
+
return
|
|
309
|
+
self.app._after_action("Task deleted.")
|
|
310
|
+
|
|
240
311
|
def _refresh_data(self) -> None:
|
|
241
312
|
"""Fetch and display tasks for the current queue and active tab."""
|
|
242
313
|
backend = self.backend
|
|
@@ -293,6 +364,10 @@ class QueueList(ListView):
|
|
|
293
364
|
|
|
294
365
|
telemetry: reactive[BackendTelemetry | None] = reactive(None)
|
|
295
366
|
|
|
367
|
+
BINDINGS = [
|
|
368
|
+
Binding("p", "purge", "Purge"),
|
|
369
|
+
]
|
|
370
|
+
|
|
296
371
|
def __init__(self, **kwargs: Any) -> None:
|
|
297
372
|
super().__init__(**kwargs)
|
|
298
373
|
self._items: dict[str, QueueItem] = {}
|
|
@@ -302,12 +377,10 @@ class QueueList(ListView):
|
|
|
302
377
|
self.border_title = "Queues"
|
|
303
378
|
|
|
304
379
|
def watch_telemetry(self, telemetry: BackendTelemetry | None) -> None:
|
|
305
|
-
"""Refresh queue labels when a new telemetry snapshot arrives."""
|
|
306
380
|
if telemetry is not None:
|
|
307
381
|
self.update_telemetry(telemetry)
|
|
308
382
|
|
|
309
383
|
def update_telemetry(self, telemetry: BackendTelemetry) -> None:
|
|
310
|
-
"""Refresh queue labels from a new telemetry snapshot."""
|
|
311
384
|
queues = telemetry.queues
|
|
312
385
|
for queue_name, stats in sorted(queues.items()):
|
|
313
386
|
theme = BUILTIN_THEMES[self.app.theme]
|
|
@@ -336,21 +409,40 @@ class QueueList(ListView):
|
|
|
336
409
|
self._notify_selection(target)
|
|
337
410
|
|
|
338
411
|
def _notify_selection(self, queue_name: str) -> None:
|
|
339
|
-
"""Tell the app which queue to display."""
|
|
340
412
|
self.app._task_list.queue_name = queue_name
|
|
341
413
|
|
|
414
|
+
def action_purge(self) -> None:
|
|
415
|
+
item = self.highlighted_child
|
|
416
|
+
if not isinstance(item, QueueItem):
|
|
417
|
+
return
|
|
418
|
+
queue_name = item.queue_name
|
|
419
|
+
self.app.push_screen(
|
|
420
|
+
PurgeScreen(queue_name),
|
|
421
|
+
lambda confirmed: self._do_purge(confirmed, queue_name),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def _do_purge(self, confirmed: bool, queue_name: str) -> None:
|
|
425
|
+
if not confirmed:
|
|
426
|
+
return
|
|
427
|
+
try:
|
|
428
|
+
self.app.backend.purge(queue_name)
|
|
429
|
+
except Exception: # noqa: BLE001
|
|
430
|
+
logger.exception("Purge failed for %r", queue_name)
|
|
431
|
+
self.app.notify("Purge failed.", severity="error")
|
|
432
|
+
return
|
|
433
|
+
self.app._after_action(f"Purged queue {queue_name}.")
|
|
434
|
+
|
|
342
435
|
|
|
343
436
|
class InspectorApp(App):
|
|
344
437
|
"""Threadmill TUI inspector with backend/queue/task panes."""
|
|
345
438
|
|
|
346
439
|
CSS_PATH = "inspector.scss"
|
|
347
440
|
BINDINGS = [
|
|
348
|
-
Binding("q", "quit", "Quit"),
|
|
349
|
-
Binding("f5", "refresh", "Refresh"),
|
|
350
441
|
*(
|
|
351
442
|
Binding(key, f"switch_tab('tab-{tab_id}')", tab_id.capitalize())
|
|
352
443
|
for tab_id, key in TAB_KEYS.items()
|
|
353
444
|
),
|
|
445
|
+
Binding("q", "quit", "Quit"),
|
|
354
446
|
]
|
|
355
447
|
|
|
356
448
|
backend: reactive[ThreadmillTaskBackend] = reactive(None)
|
|
@@ -417,8 +509,9 @@ class InspectorApp(App):
|
|
|
417
509
|
"""Exit the TUI."""
|
|
418
510
|
self.exit()
|
|
419
511
|
|
|
420
|
-
def
|
|
421
|
-
"""Refresh
|
|
512
|
+
def _after_action(self, message: str) -> None:
|
|
513
|
+
"""Refresh telemetry and tasks after a mutating queue action."""
|
|
514
|
+
self.notify(message)
|
|
422
515
|
self._refresh_telemetry()
|
|
423
516
|
self._task_list.refresh_tasks()
|
|
424
517
|
|
|
@@ -2,6 +2,11 @@ Screen {
|
|
|
2
2
|
background: $background;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
ConfirmScreen,
|
|
6
|
+
PurgeScreen {
|
|
7
|
+
align: center middle;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
* {
|
|
6
11
|
scrollbar-color: $primary 10%;
|
|
7
12
|
scrollbar-color-hover: $primary 80%;
|
|
@@ -104,3 +109,29 @@ TaskDetail {
|
|
|
104
109
|
overflow: scroll;
|
|
105
110
|
padding: 0 1;
|
|
106
111
|
}
|
|
112
|
+
|
|
113
|
+
#dialog {
|
|
114
|
+
width: 60;
|
|
115
|
+
height: auto;
|
|
116
|
+
background: $surface;
|
|
117
|
+
padding: 1 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#dialog.warning {
|
|
121
|
+
border: thick $warning;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#dialog.danger {
|
|
125
|
+
border: thick $error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#prompt {
|
|
129
|
+
text-align: center;
|
|
130
|
+
margin-bottom: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#instruction {
|
|
134
|
+
text-align: center;
|
|
135
|
+
color: $text-muted;
|
|
136
|
+
margin-top: 1;
|
|
137
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Modal confirmation screens for inspector queue actions."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.events import Key
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Input, Label
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
11
|
+
"""Yes/no confirmation: Enter confirms, Esc cancels."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, prompt: str, *, danger: bool = False) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._prompt = prompt
|
|
16
|
+
self._danger = danger
|
|
17
|
+
|
|
18
|
+
def compose(self) -> ComposeResult:
|
|
19
|
+
with Vertical(id="dialog", classes="danger" if self._danger else "warning"):
|
|
20
|
+
yield Label(self._prompt, id="prompt")
|
|
21
|
+
yield Label("Press Enter to confirm, Esc to cancel.", id="instruction")
|
|
22
|
+
|
|
23
|
+
def on_key(self, event: Key) -> None:
|
|
24
|
+
match event.key:
|
|
25
|
+
case "enter":
|
|
26
|
+
self.dismiss(True)
|
|
27
|
+
case "escape":
|
|
28
|
+
self.dismiss(False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PurgeScreen(ModalScreen[bool]):
|
|
32
|
+
"""Type the queue name to confirm a purge."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, queue_name: str) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._queue_name = queue_name
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Vertical(id="dialog", classes="danger"):
|
|
40
|
+
yield Label(f"Purge queue [bold]{self._queue_name}[/bold]?", id="prompt")
|
|
41
|
+
yield Label(
|
|
42
|
+
"Type the queue name to confirm, Esc to cancel.", id="instruction"
|
|
43
|
+
)
|
|
44
|
+
yield Input(placeholder=self._queue_name, id="confirm-input")
|
|
45
|
+
|
|
46
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
47
|
+
if event.value == self._queue_name:
|
|
48
|
+
self.dismiss(True)
|
|
49
|
+
|
|
50
|
+
def on_key(self, event: Key) -> None:
|
|
51
|
+
if event.key == "escape":
|
|
52
|
+
self.dismiss(False)
|
|
@@ -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
|