threadmill 0.1.0__tar.gz → 0.2.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.1.0 → threadmill-0.2.0}/PKG-INFO +46 -55
- {threadmill-0.1.0 → threadmill-0.2.0}/README.md +42 -53
- {threadmill-0.1.0 → threadmill-0.2.0}/pyproject.toml +6 -1
- {threadmill-0.1.0 → threadmill-0.2.0}/threadmill/__init__.py +1 -1
- {threadmill-0.1.0 → threadmill-0.2.0}/threadmill/_version.py +3 -3
- threadmill-0.2.0/threadmill/backends/base.py +145 -0
- threadmill-0.2.0/threadmill/backends/lua/acknowledge.lua +24 -0
- threadmill-0.2.0/threadmill/backends/lua/acquire.lua +44 -0
- threadmill-0.2.0/threadmill/backends/lua/mover.lua +19 -0
- threadmill-0.2.0/threadmill/backends/lua/reaper.lua +36 -0
- threadmill-0.2.0/threadmill/backends/redis.py +324 -0
- threadmill-0.2.0/threadmill/exceptions.py +7 -0
- {threadmill-0.1.0 → threadmill-0.2.0}/threadmill/executor.py +48 -77
- threadmill-0.2.0/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.1.0 → threadmill-0.2.0}/threadmill/management/commands/threadmill.py +0 -9
- threadmill-0.1.0/threadmill/backends.py +0 -34
- {threadmill-0.1.0 → threadmill-0.2.0}/LICENSE +0 -0
- {threadmill-0.1.0/threadmill/management → threadmill-0.2.0/threadmill/backends}/__init__.py +0 -0
- {threadmill-0.1.0/threadmill/management/commands → threadmill-0.2.0/threadmill/management}/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadmill
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.0
|
|
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
7
|
Requires-Python: >=3.12
|
|
@@ -27,6 +27,7 @@ Classifier: Framework :: Django
|
|
|
27
27
|
Classifier: Framework :: Django :: 6.1
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: django>=6.1a1
|
|
30
|
+
Requires-Dist: redis>=5.0 ; extra == "redis"
|
|
30
31
|
Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
|
|
31
32
|
Project-URL: Documentation, https://github.com/codingjoe/threadmill/
|
|
32
33
|
Project-URL: Funding, https://github.com/sponsors/codingjoe
|
|
@@ -34,6 +35,7 @@ Project-URL: Homepage, https://github.com/codingjoe/threadmill
|
|
|
34
35
|
Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
|
|
35
36
|
Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
|
|
36
37
|
Project-URL: Source, https://github.com/codingjoe/threadmill
|
|
38
|
+
Provides-Extra: redis
|
|
37
39
|
|
|
38
40
|
# Threadmill
|
|
39
41
|
|
|
@@ -41,7 +43,7 @@ Project-URL: Source, https://github.com/codingjoe/threadmill
|
|
|
41
43
|
<picture>
|
|
42
44
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
|
|
43
45
|
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
|
|
44
|
-
<img alt="
|
|
46
|
+
<img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
|
|
45
47
|
</picture>
|
|
46
48
|
<br>
|
|
47
49
|
<a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
|
|
@@ -65,22 +67,37 @@ Project-URL: Source, https://github.com/codingjoe/threadmill
|
|
|
65
67
|
[](https://codecov.io/gh/codingjoe/threadmill)
|
|
66
68
|
[](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
|
|
67
69
|
|
|
70
|
+
## Sponsors
|
|
71
|
+
|
|
72
|
+
[](https://github.com/sponsors/codingjoe)
|
|
73
|
+
|
|
68
74
|
## Setup
|
|
69
75
|
|
|
70
|
-
You need to have [Django's Task framework][django-tasks]
|
|
76
|
+
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
71
77
|
|
|
72
78
|
```console
|
|
73
|
-
uv add threadmill
|
|
79
|
+
uv add threadmill[redis]
|
|
74
80
|
```
|
|
75
81
|
|
|
76
|
-
Add `threadmill` to your `INSTALLED_APPS` in `settings.py
|
|
82
|
+
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
83
|
+
and configure the task backend:
|
|
77
84
|
|
|
78
85
|
```python
|
|
79
86
|
# settings.py
|
|
87
|
+
import os
|
|
88
|
+
|
|
80
89
|
INSTALLED_APPS = [
|
|
81
90
|
"threadmill",
|
|
82
91
|
# ...
|
|
83
92
|
]
|
|
93
|
+
|
|
94
|
+
TASKS = {
|
|
95
|
+
"default": {
|
|
96
|
+
"BACKEND": "threadmill.backends.redis.RedisTaskBackend",
|
|
97
|
+
"REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
98
|
+
},
|
|
99
|
+
# ...
|
|
100
|
+
}
|
|
84
101
|
```
|
|
85
102
|
|
|
86
103
|
Finally, you launch the worker pool:
|
|
@@ -91,9 +108,11 @@ uv run manage.py threadmill
|
|
|
91
108
|
|
|
92
109
|
## Usage
|
|
93
110
|
|
|
111
|
+
### Workers
|
|
112
|
+
|
|
94
113
|
The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
95
114
|
|
|
96
|
-
|
|
115
|
+
#### Utilization
|
|
97
116
|
|
|
98
117
|
Depending on your workload, you can tweak the number of processes and threads.
|
|
99
118
|
Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
|
|
@@ -102,7 +121,7 @@ Processes allow for parallel compute (no GIL) while threads are great for low-me
|
|
|
102
121
|
uv run manage.py threadmill --processes 4 --threads 2
|
|
103
122
|
```
|
|
104
123
|
|
|
105
|
-
|
|
124
|
+
#### Health
|
|
106
125
|
|
|
107
126
|
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
108
127
|
|
|
@@ -114,62 +133,34 @@ This will restart the workers after 1000 tasks have been processed, with a rando
|
|
|
114
133
|
|
|
115
134
|
Should a worker crash or be killed, the pool will automatically restart it.
|
|
116
135
|
|
|
117
|
-
|
|
136
|
+
#### Shutdown
|
|
118
137
|
|
|
119
138
|
A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
|
|
120
|
-
All workers will finish the tasks they acquired and
|
|
139
|
+
All workers will finish the tasks they acquired and acknowledge them.
|
|
121
140
|
|
|
122
141
|
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
123
142
|
which might be useful for draining a one-off queue.
|
|
124
143
|
|
|
125
|
-
###
|
|
126
|
-
|
|
127
|
-
You can prefetch tasks from a queue to avoid IO latency bottlenecks.
|
|
128
|
-
However, this will increase the memory usage of the worker pool.
|
|
129
|
-
|
|
130
|
-
```console
|
|
131
|
-
uv run manage.py threadmill --prefetch 100
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Task Timeouts
|
|
135
|
-
|
|
136
|
-
> [!WARNING]
|
|
137
|
-
> Work in progress, this feature is not yet stable.
|
|
138
|
-
|
|
139
|
-
Task timeouts are important to ensure the long-term health of your pool.
|
|
140
|
-
However, they need to be aligned with your queueing system's timeout settings.
|
|
141
|
-
The message queue needs to requeue a task that hasn't been acknowledged within the timeout.
|
|
144
|
+
### Redis Backend Options
|
|
142
145
|
|
|
143
|
-
|
|
146
|
+
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
147
|
+
`TASKS` configuration:
|
|
144
148
|
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
| Option | Default | Description |
|
|
150
|
+
| ----------------- | ---------------------- | ------------------------------------------------------------ |
|
|
151
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
152
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
153
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
154
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
147
155
|
|
|
148
|
-
|
|
156
|
+
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
157
|
+
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
158
|
+
worst-case task runtime.
|
|
149
159
|
|
|
150
|
-
|
|
160
|
+
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
161
|
+
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
162
|
+
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
163
|
+
on cross-slot operations.
|
|
151
164
|
|
|
152
|
-
|
|
153
|
-
class AcknowledgeableTaskBackend(BaseTaskBackend, ABC):
|
|
154
|
-
"""Provide an interface for tasks queues to be processed by the executor."""
|
|
155
|
-
|
|
156
|
-
def acquire(
|
|
157
|
-
self, *queue_names: str, timeout: datetime.timedelta | None = None
|
|
158
|
-
) -> TaskResult:
|
|
159
|
-
"""
|
|
160
|
-
Return and lock the next task to be processed without removing it from the queue.
|
|
161
|
-
|
|
162
|
-
Args:
|
|
163
|
-
queue_names: The names of the queues to acquire tasks from.
|
|
164
|
-
timeout: The maximum time to wait for a task. If None, wait indefinitely.
|
|
165
|
-
|
|
166
|
-
Raises:
|
|
167
|
-
TimeoutError: If no task is available within the specified timeout.
|
|
168
|
-
"""
|
|
169
|
-
raise NotImplementedError
|
|
170
|
-
|
|
171
|
-
def acknowledge(self, task_result: TaskResult) -> None:
|
|
172
|
-
"""Remove the task from the queue and publish the result."""
|
|
173
|
-
raise NotImplementedError
|
|
174
|
-
```
|
|
165
|
+
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
175
166
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<picture>
|
|
5
5
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
|
|
6
6
|
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
|
|
7
|
-
<img alt="
|
|
7
|
+
<img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
|
|
8
8
|
</picture>
|
|
9
9
|
<br>
|
|
10
10
|
<a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
|
|
@@ -28,22 +28,37 @@
|
|
|
28
28
|
[](https://codecov.io/gh/codingjoe/threadmill)
|
|
29
29
|
[](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
|
|
30
30
|
|
|
31
|
+
## Sponsors
|
|
32
|
+
|
|
33
|
+
[](https://github.com/sponsors/codingjoe)
|
|
34
|
+
|
|
31
35
|
## Setup
|
|
32
36
|
|
|
33
|
-
You need to have [Django's Task framework][django-tasks]
|
|
37
|
+
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
34
38
|
|
|
35
39
|
```console
|
|
36
|
-
uv add threadmill
|
|
40
|
+
uv add threadmill[redis]
|
|
37
41
|
```
|
|
38
42
|
|
|
39
|
-
Add `threadmill` to your `INSTALLED_APPS` in `settings.py
|
|
43
|
+
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
44
|
+
and configure the task backend:
|
|
40
45
|
|
|
41
46
|
```python
|
|
42
47
|
# settings.py
|
|
48
|
+
import os
|
|
49
|
+
|
|
43
50
|
INSTALLED_APPS = [
|
|
44
51
|
"threadmill",
|
|
45
52
|
# ...
|
|
46
53
|
]
|
|
54
|
+
|
|
55
|
+
TASKS = {
|
|
56
|
+
"default": {
|
|
57
|
+
"BACKEND": "threadmill.backends.redis.RedisTaskBackend",
|
|
58
|
+
"REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
59
|
+
},
|
|
60
|
+
# ...
|
|
61
|
+
}
|
|
47
62
|
```
|
|
48
63
|
|
|
49
64
|
Finally, you launch the worker pool:
|
|
@@ -54,9 +69,11 @@ uv run manage.py threadmill
|
|
|
54
69
|
|
|
55
70
|
## Usage
|
|
56
71
|
|
|
72
|
+
### Workers
|
|
73
|
+
|
|
57
74
|
The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
58
75
|
|
|
59
|
-
|
|
76
|
+
#### Utilization
|
|
60
77
|
|
|
61
78
|
Depending on your workload, you can tweak the number of processes and threads.
|
|
62
79
|
Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
|
|
@@ -65,7 +82,7 @@ Processes allow for parallel compute (no GIL) while threads are great for low-me
|
|
|
65
82
|
uv run manage.py threadmill --processes 4 --threads 2
|
|
66
83
|
```
|
|
67
84
|
|
|
68
|
-
|
|
85
|
+
#### Health
|
|
69
86
|
|
|
70
87
|
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
71
88
|
|
|
@@ -77,61 +94,33 @@ This will restart the workers after 1000 tasks have been processed, with a rando
|
|
|
77
94
|
|
|
78
95
|
Should a worker crash or be killed, the pool will automatically restart it.
|
|
79
96
|
|
|
80
|
-
|
|
97
|
+
#### Shutdown
|
|
81
98
|
|
|
82
99
|
A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
|
|
83
|
-
All workers will finish the tasks they acquired and
|
|
100
|
+
All workers will finish the tasks they acquired and acknowledge them.
|
|
84
101
|
|
|
85
102
|
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
86
103
|
which might be useful for draining a one-off queue.
|
|
87
104
|
|
|
88
|
-
###
|
|
89
|
-
|
|
90
|
-
You can prefetch tasks from a queue to avoid IO latency bottlenecks.
|
|
91
|
-
However, this will increase the memory usage of the worker pool.
|
|
92
|
-
|
|
93
|
-
```console
|
|
94
|
-
uv run manage.py threadmill --prefetch 100
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Task Timeouts
|
|
98
|
-
|
|
99
|
-
> [!WARNING]
|
|
100
|
-
> Work in progress, this feature is not yet stable.
|
|
101
|
-
|
|
102
|
-
Task timeouts are important to ensure the long-term health of your pool.
|
|
103
|
-
However, they need to be aligned with your queueing system's timeout settings.
|
|
104
|
-
The message queue needs to requeue a task that hasn't been acknowledged within the timeout.
|
|
105
|
+
### Redis Backend Options
|
|
105
106
|
|
|
106
|
-
|
|
107
|
+
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
108
|
+
`TASKS` configuration:
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
| Option | Default | Description |
|
|
111
|
+
| ----------------- | ---------------------- | ------------------------------------------------------------ |
|
|
112
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
113
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
114
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
115
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
118
|
+
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
119
|
+
worst-case task runtime.
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
122
|
+
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
123
|
+
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
124
|
+
on cross-slot operations.
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
class AcknowledgeableTaskBackend(BaseTaskBackend, ABC):
|
|
117
|
-
"""Provide an interface for tasks queues to be processed by the executor."""
|
|
118
|
-
|
|
119
|
-
def acquire(
|
|
120
|
-
self, *queue_names: str, timeout: datetime.timedelta | None = None
|
|
121
|
-
) -> TaskResult:
|
|
122
|
-
"""
|
|
123
|
-
Return and lock the next task to be processed without removing it from the queue.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
queue_names: The names of the queues to acquire tasks from.
|
|
127
|
-
timeout: The maximum time to wait for a task. If None, wait indefinitely.
|
|
128
|
-
|
|
129
|
-
Raises:
|
|
130
|
-
TimeoutError: If no task is available within the specified timeout.
|
|
131
|
-
"""
|
|
132
|
-
raise NotImplementedError
|
|
133
|
-
|
|
134
|
-
def acknowledge(self, task_result: TaskResult) -> None:
|
|
135
|
-
"""Remove the task from the queue and publish the result."""
|
|
136
|
-
raise NotImplementedError
|
|
137
|
-
```
|
|
126
|
+
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
@@ -35,6 +35,9 @@ classifiers = [
|
|
|
35
35
|
requires-python = ">=3.12"
|
|
36
36
|
dependencies = ["django>=6.1a1"]
|
|
37
37
|
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
redis = ["redis>=5.0"]
|
|
40
|
+
|
|
38
41
|
[project.urls]
|
|
39
42
|
# https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
|
|
40
43
|
Homepage = "https://github.com/codingjoe/threadmill"
|
|
@@ -56,9 +59,9 @@ minversion = "6.0"
|
|
|
56
59
|
addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
|
|
57
60
|
testpaths = ["tests"]
|
|
58
61
|
DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
|
|
62
|
+
asyncio_mode = "auto"
|
|
59
63
|
markers = [
|
|
60
64
|
"benchmark: mark benchmark tests.",
|
|
61
|
-
"integration: mark integration tests.",
|
|
62
65
|
]
|
|
63
66
|
|
|
64
67
|
[tool.coverage.run]
|
|
@@ -91,6 +94,7 @@ combine-as-imports = true
|
|
|
91
94
|
split-on-trailing-comma = true
|
|
92
95
|
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
93
96
|
force-wrap-aliases = true
|
|
97
|
+
known-first-party = ["threadmill", "tests"]
|
|
94
98
|
|
|
95
99
|
[tool.ruff.lint.pydocstyle]
|
|
96
100
|
convention = "pep257"
|
|
@@ -105,4 +109,5 @@ test = [
|
|
|
105
109
|
"pytest-asyncio",
|
|
106
110
|
"pytest-cov",
|
|
107
111
|
"pytest-django",
|
|
112
|
+
"redis>=5.0",
|
|
108
113
|
]
|
|
@@ -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.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gb135558ff'
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
from abc import ABC
|
|
9
|
+
|
|
10
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
11
|
+
from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
|
|
12
|
+
from django.tasks.backends.base import BaseTaskBackend
|
|
13
|
+
from django.tasks.base import TaskError
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Broker(threading.Thread):
|
|
18
|
+
"""Backend maintenance thread launched by the task executor."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
backend: ThreadmillTaskBackend | None = None,
|
|
23
|
+
*,
|
|
24
|
+
interval: datetime.timedelta = datetime.timedelta(seconds=1),
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(daemon=True)
|
|
27
|
+
self.backend = backend
|
|
28
|
+
self.interval = interval
|
|
29
|
+
self.shutdown_requested = threading.Event()
|
|
30
|
+
|
|
31
|
+
def main(self) -> None:
|
|
32
|
+
"""Perform one maintenance pass."""
|
|
33
|
+
|
|
34
|
+
def run(self) -> None:
|
|
35
|
+
while not self.shutdown_requested.wait(self.interval.total_seconds()):
|
|
36
|
+
self.main()
|
|
37
|
+
|
|
38
|
+
def shutdown(self) -> None:
|
|
39
|
+
"""Request graceful shutdown."""
|
|
40
|
+
self.shutdown_requested.set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_datetime(value: object) -> object:
|
|
44
|
+
"""Parse an ISO datetime string, returning the value unchanged if not parseable."""
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
try:
|
|
47
|
+
return datetime.datetime.fromisoformat(value)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return value
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TaskResultEncoder(DjangoJSONEncoder):
|
|
54
|
+
"""JSON encoder for TaskResult and TaskError objects."""
|
|
55
|
+
|
|
56
|
+
def default(self, o):
|
|
57
|
+
if isinstance(o, (TaskResult, TaskError)):
|
|
58
|
+
return {
|
|
59
|
+
field.name: getattr(o, field.name)
|
|
60
|
+
for field in dataclasses.fields(type(o))
|
|
61
|
+
}
|
|
62
|
+
if isinstance(o, Task):
|
|
63
|
+
return {
|
|
64
|
+
field.name: getattr(o, field.name)
|
|
65
|
+
for field in dataclasses.fields(Task)
|
|
66
|
+
if field.name != "func" # Exclude the function object itself
|
|
67
|
+
} | {"func": o.module_path}
|
|
68
|
+
return super().default(o)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
72
|
+
"""Interface for task queues to be processed by the executor."""
|
|
73
|
+
|
|
74
|
+
supports_async_task = True
|
|
75
|
+
supports_get_result = True
|
|
76
|
+
broker_class: type[Broker] | None = None
|
|
77
|
+
|
|
78
|
+
result_ttl: datetime.timedelta | None = None
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def serialize_task_result(task_result: TaskResult) -> str:
|
|
82
|
+
return json.dumps(task_result, cls=TaskResultEncoder)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def deserialize_task_result(cls, data: str) -> TaskResult:
|
|
86
|
+
def _object_hook(d: dict) -> dict | TaskResult:
|
|
87
|
+
if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
|
|
88
|
+
task_data = d["task"]
|
|
89
|
+
func = import_string(task_data["func"])
|
|
90
|
+
if isinstance(func, cls.task_class):
|
|
91
|
+
func = func.func
|
|
92
|
+
d["task"] = cls.task_class(
|
|
93
|
+
func=func,
|
|
94
|
+
**{
|
|
95
|
+
field.name: _parse_datetime(task_data[field.name])
|
|
96
|
+
for field in dataclasses.fields(cls.task_class)
|
|
97
|
+
if field.name not in {"func", "takes_context"}
|
|
98
|
+
and field.name in task_data
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
d["status"] = TaskResultStatus(d["status"])
|
|
102
|
+
d["errors"] = [TaskError(**error) for error in d["errors"]]
|
|
103
|
+
return_value = d.pop("_return_value", None)
|
|
104
|
+
for key, value in d.items():
|
|
105
|
+
d[key] = _parse_datetime(value)
|
|
106
|
+
result = TaskResult(**d)
|
|
107
|
+
object.__setattr__(result, "_return_value", return_value)
|
|
108
|
+
return result
|
|
109
|
+
return d
|
|
110
|
+
|
|
111
|
+
return json.loads(data, object_hook=_object_hook)
|
|
112
|
+
|
|
113
|
+
def acquire(
|
|
114
|
+
self,
|
|
115
|
+
*queue_names: str,
|
|
116
|
+
timeout: datetime.timedelta | None = None,
|
|
117
|
+
worker: str = "",
|
|
118
|
+
) -> TaskResult:
|
|
119
|
+
"""
|
|
120
|
+
Return and lock the next task to be processed without removing it from the queue.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
queue_names: The names of the queues to acquire tasks from.
|
|
124
|
+
timeout: The maximum time to wait for a task. If None, wait indefinitely.
|
|
125
|
+
worker: The name of the worker thread acquiring the task.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
TimeoutError: If no task is available within the specified timeout.
|
|
129
|
+
queue.Empty: If no task is available and timeout is None.
|
|
130
|
+
"""
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
def acknowledge(self, task_result: TaskResult) -> None:
|
|
134
|
+
"""Remove the task from the queue and publish the result."""
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
|
|
137
|
+
def peek(
|
|
138
|
+
self,
|
|
139
|
+
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
140
|
+
*,
|
|
141
|
+
status: TaskResultStatus | None = None,
|
|
142
|
+
count: int = 1,
|
|
143
|
+
) -> collections.abc.Generator[TaskResult, None, None]:
|
|
144
|
+
"""Yield acknowledged results from a queue, optionally filtered by status."""
|
|
145
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Finalize a completed task: remove it from the running set, persist the
|
|
2
|
+
-- result with a TTL, delete the task data hash, and add the result to the
|
|
3
|
+
-- results history set. Also evict results whose finish score falls outside the
|
|
4
|
+
-- retention window.
|
|
5
|
+
--
|
|
6
|
+
-- KEYS[1] -- running set (ZSET)
|
|
7
|
+
-- KEYS[2] -- result key (STRING, stores serialized TaskResult)
|
|
8
|
+
-- KEYS[3] -- task data key (HASH, deleted after acknowledge)
|
|
9
|
+
-- KEYS[4] -- results history set (ZSET, ordered by finish time)
|
|
10
|
+
-- ARGV[1] -- task ID
|
|
11
|
+
-- ARGV[2] -- serialized TaskResult JSON
|
|
12
|
+
-- ARGV[3] -- result TTL in seconds
|
|
13
|
+
-- ARGV[4] -- finish timestamp in milliseconds (score for the results set)
|
|
14
|
+
-- Returns: 1 on success, 0 if task was not in the running set
|
|
15
|
+
|
|
16
|
+
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
17
|
+
if removed == 0 then
|
|
18
|
+
return 0 -- Task already reaped, skip
|
|
19
|
+
end
|
|
20
|
+
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3])
|
|
21
|
+
redis.call('DEL', KEYS[3])
|
|
22
|
+
redis.call('ZADD', KEYS[4], ARGV[4], ARGV[1])
|
|
23
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[4], 0, tonumber(ARGV[4]) - tonumber(ARGV[3]) * 1000)
|
|
24
|
+
return 1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Atomically pop the lowest-scored task from any of the given priority queues,
|
|
2
|
+
-- update its JSON data with worker info, and move it directly to the running
|
|
3
|
+
-- set. Iterates queues in key order and returns the first available task.
|
|
4
|
+
--
|
|
5
|
+
-- KEYS[1..N] -- interleaved running keys and queue keys, one pair per queue:
|
|
6
|
+
-- KEYS[1] = running set, KEYS[2] = queue set, KEYS[3] = running,
|
|
7
|
+
-- KEYS[4] = queue, etc.
|
|
8
|
+
-- ARGV[1] -- current time in milliseconds
|
|
9
|
+
-- ARGV[2] -- current time as ISO-8601 string
|
|
10
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
|
|
11
|
+
-- ARGV[4] -- number of queue pairs (N/2)
|
|
12
|
+
-- ARGV[5] -- worker name
|
|
13
|
+
-- ARGV[6] -- lease TTL in milliseconds
|
|
14
|
+
-- Returns: updated serialized data on success, nil if all queues are empty.
|
|
15
|
+
|
|
16
|
+
local num_queues = tonumber(ARGV[4])
|
|
17
|
+
local lease_ttl_ms = tonumber(ARGV[6])
|
|
18
|
+
for i = 1, num_queues do
|
|
19
|
+
local result = redis.call('ZPOPMIN', KEYS[i * 2])
|
|
20
|
+
if #result > 0 then
|
|
21
|
+
local task_id = result[1]
|
|
22
|
+
local data = redis.call('HGET', ARGV[3] .. task_id, 'data')
|
|
23
|
+
if data then
|
|
24
|
+
local ok, parsed = pcall(cjson.decode, data)
|
|
25
|
+
if ok then
|
|
26
|
+
parsed.status = 'RUNNING'
|
|
27
|
+
parsed.last_attempted_at = ARGV[2]
|
|
28
|
+
if not parsed.started_at then
|
|
29
|
+
parsed.started_at = ARGV[2]
|
|
30
|
+
end
|
|
31
|
+
if not parsed.worker_ids then
|
|
32
|
+
parsed.worker_ids = {}
|
|
33
|
+
end
|
|
34
|
+
table.insert(parsed.worker_ids, ARGV[5])
|
|
35
|
+
local updated_data = cjson.encode(parsed)
|
|
36
|
+
local deadline = tonumber(ARGV[1]) + lease_ttl_ms
|
|
37
|
+
redis.call('ZADD', KEYS[i * 2 - 1], deadline, task_id)
|
|
38
|
+
redis.call('HSET', ARGV[3] .. task_id, 'data', updated_data)
|
|
39
|
+
return updated_data
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
return nil
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Move tasks whose scheduled time has passed from the deferred set to the active
|
|
2
|
+
-- priority queue. Only processes up to a batch limit per call.
|
|
3
|
+
--
|
|
4
|
+
-- KEYS[1] -- deferred set (ZSET, scored by run_after timestamp)
|
|
5
|
+
-- KEYS[2] -- active priority queue (ZSET, scored by priority+time)
|
|
6
|
+
-- ARGV[1] -- current time in milliseconds (all scores <= this are due)
|
|
7
|
+
-- ARGV[2] -- task key prefix (e.g. "threadmill:default:task:")
|
|
8
|
+
-- ARGV[3] -- maximum number of tasks to move per call (batch size)
|
|
9
|
+
-- Returns: number of tasks moved
|
|
10
|
+
|
|
11
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[3]))
|
|
12
|
+
for _, task_id in ipairs(due) do
|
|
13
|
+
redis.call('ZREM', KEYS[1], task_id)
|
|
14
|
+
local score = redis.call('HGET', ARGV[2] .. task_id, 'score')
|
|
15
|
+
if score then
|
|
16
|
+
redis.call('ZADD', KEYS[2], score, task_id)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
return #due
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Fail tasks whose processing lease has expired.
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] -- running set (ZSET)
|
|
4
|
+
-- KEYS[2] -- results history set (ZSET)
|
|
5
|
+
-- ARGV[1] -- current time in milliseconds (for score comparison)
|
|
6
|
+
-- ARGV[2] -- task key prefix (e.g. "threadmill:default:task:")
|
|
7
|
+
-- ARGV[3] -- result key prefix (e.g. "threadmill:default:result:")
|
|
8
|
+
-- ARGV[4] -- batch size
|
|
9
|
+
-- ARGV[5] -- result TTL in seconds
|
|
10
|
+
-- ARGV[6] -- finished_at as ISO format string
|
|
11
|
+
-- Returns: number of tasks failed
|
|
12
|
+
|
|
13
|
+
local stale = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[4]))
|
|
14
|
+
for _, task_id in ipairs(stale) do
|
|
15
|
+
local data = redis.call('HGET', ARGV[2] .. task_id, 'data')
|
|
16
|
+
if data then
|
|
17
|
+
local ok, parsed = pcall(cjson.decode, data)
|
|
18
|
+
if ok then
|
|
19
|
+
parsed.status = 'FAILED'
|
|
20
|
+
parsed.finished_at = ARGV[6]
|
|
21
|
+
if not parsed.errors then
|
|
22
|
+
parsed.errors = {}
|
|
23
|
+
end
|
|
24
|
+
table.insert(parsed.errors, {
|
|
25
|
+
exception_class_path = 'threadmill.exceptions.AcknowledgementTimeout',
|
|
26
|
+
traceback = 'Task processing lease expired.'
|
|
27
|
+
})
|
|
28
|
+
local failed_data = cjson.encode(parsed)
|
|
29
|
+
redis.call('ZREM', KEYS[1], task_id)
|
|
30
|
+
redis.call('SET', ARGV[3] .. task_id, failed_data, 'EX', ARGV[5])
|
|
31
|
+
redis.call('DEL', ARGV[2] .. task_id)
|
|
32
|
+
redis.call('ZADD', KEYS[2], tonumber(ARGV[1]), task_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
return #stale
|