threadmill 0.7.2__tar.gz → 0.8.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.7.2 → threadmill-0.8.0}/PKG-INFO +59 -25
- {threadmill-0.7.2 → threadmill-0.8.0}/README.md +56 -24
- {threadmill-0.7.2 → threadmill-0.8.0}/pyproject.toml +9 -2
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/_version.py +3 -3
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/base.py +65 -8
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/lua/acknowledge.lua +5 -0
- threadmill-0.8.0/threadmill/backends/lua/acquire.lua +57 -0
- threadmill-0.8.0/threadmill/backends/lua/reaper.lua +23 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/redis.py +218 -59
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/executor.py +142 -49
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/commands/threadmill.py +15 -2
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/retry.py +5 -1
- threadmill-0.7.2/threadmill/backends/lua/acquire.lua +0 -49
- threadmill-0.7.2/threadmill/backends/lua/reaper.lua +0 -40
- {threadmill-0.7.2 → threadmill-0.8.0}/LICENSE +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/__init__.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/apps.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/exceptions.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/app.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/inspector.scss +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/screens.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/telemetry.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/__init__.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/signals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadmill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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>
|
|
@@ -19,6 +19,8 @@ Classifier: Programming Language :: Python
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Programming Language :: Python :: Free Threading
|
|
23
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
22
24
|
Classifier: Topic :: Communications :: Email
|
|
23
25
|
Classifier: Topic :: Software Development
|
|
24
26
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
@@ -49,10 +51,18 @@ Provides-Extra: redis
|
|
|
49
51
|
<a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
|
|
50
52
|
</p>
|
|
51
53
|
|
|
52
|
-
# Threadmill [](https://pypi.python.org/pypi/threadmill/) [](https://codecov.io/gh/codingjoe/threadmill) [](https://raw.githubusercontent.com/codingjoe/threadmill/main/LICENSE)
|
|
53
55
|
|
|
54
56
|
**Durable high-performance backend for Django's task framework.**
|
|
55
57
|
|
|
58
|
+
<p align="center">
|
|
59
|
+
<picture>
|
|
60
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-dark.svg">
|
|
61
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-light.svg">
|
|
62
|
+
<img alt="Tasks per second with one worker process each: Threadmill 19,954, Threadmill (GIL) 9,215, dramatiq 7,251, huey 6,254, celery 2,497, django-tasks-db 1,814, django-tasks-rq 84." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-light.svg">
|
|
63
|
+
</picture>
|
|
64
|
+
</p>
|
|
65
|
+
|
|
56
66
|
## Design Principles
|
|
57
67
|
|
|
58
68
|
- **Durability** – Recover from any failures, even poorly written tasks.
|
|
@@ -64,7 +74,7 @@ Provides-Extra: redis
|
|
|
64
74
|
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
65
75
|
|
|
66
76
|
```console
|
|
67
|
-
uv add threadmill[redis]
|
|
77
|
+
uv add "threadmill[redis]"
|
|
68
78
|
```
|
|
69
79
|
|
|
70
80
|
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
@@ -91,7 +101,7 @@ TASKS = {
|
|
|
91
101
|
Optionally, install the inspector dependency if you want the TUI:
|
|
92
102
|
|
|
93
103
|
```console
|
|
94
|
-
uv add threadmill[inspector]
|
|
104
|
+
uv add "threadmill[inspector]"
|
|
95
105
|
```
|
|
96
106
|
|
|
97
107
|
Then launch the worker pool:
|
|
@@ -108,13 +118,24 @@ The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
|
108
118
|
|
|
109
119
|
#### Utilization
|
|
110
120
|
|
|
111
|
-
Depending on your workload, you can tweak the number of processes and threads.
|
|
112
|
-
|
|
121
|
+
Depending on your workload, you can tweak the number of worker processes and the number of threads per process. Processes always run in parallel, because each one has its own interpreter. Threads share memory, so they are cheap, but whether they run in parallel depends on your interpreter:
|
|
122
|
+
|
|
123
|
+
- On a regular GIL build, Python runs in one thread at a time. Threads still overlap waiting for IO, but CPU-bound tasks do not speed up. Raising the thread count far above the queue depth can also leave processes idle, because each process prefetches a batch sized by its thread count.
|
|
124
|
+
- On a [free-threaded build](https://peps.python.org/pep-0779/), threads run truly in parallel, so CPU-bound tasks scale across cores without the cost of extra processes.
|
|
125
|
+
|
|
126
|
+
A pool on a free-threaded interpreter therefore reaches the same throughput with one process running many threads as with many processes running one thread each, while using a single Redis connection pool and one copy of your application state:
|
|
113
127
|
|
|
114
128
|
```console
|
|
115
|
-
uv run manage.py threadmill worker --
|
|
129
|
+
uv run manage.py threadmill worker --workers 1 --threads 8
|
|
116
130
|
```
|
|
117
131
|
|
|
132
|
+
Threads all live in one process, so a crash or a `--max-tasks` recycle takes every thread down at once. One process per core, the default, confines that to a single process. Prefer threads when memory matters more than that isolation, and keep the default when it does not.
|
|
133
|
+
|
|
134
|
+
A free-threaded interpreter can be slower than a regular one for single-threaded work, so a GIL build stays the better choice for IO-bound tasks. Pick the interpreter that fits your workload rather than assuming free threading is an upgrade.
|
|
135
|
+
|
|
136
|
+
> [!WARNING]
|
|
137
|
+
> A C extension that does not declare free-threading support re-enables the GIL for the whole process, which silently costs you all thread parallelism. `hiredis` is a common offender: installing `redis[hiredis]` enables it on import. The worker logs a warning when it detects that the GIL was re-enabled.
|
|
138
|
+
|
|
118
139
|
#### Health
|
|
119
140
|
|
|
120
141
|
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
@@ -125,12 +146,15 @@ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
|
|
|
125
146
|
|
|
126
147
|
This will restart the workers after 1000 tasks have been processed, with a random jitter of up to 100 tasks to avoid all workers restarting at the same time.
|
|
127
148
|
|
|
149
|
+
The limit is soft. A worker drains its buffer and the batch in hand before it stops. It can then run about twice `--prefetch-count` tasks more than the configured maximum.
|
|
150
|
+
|
|
128
151
|
Should a worker crash or be killed, the pool will automatically restart it.
|
|
129
152
|
|
|
130
153
|
#### Shutdown
|
|
131
154
|
|
|
132
|
-
A graceful shutdown is possible with
|
|
133
|
-
All workers
|
|
155
|
+
A graceful shutdown is possible with `SIGTERM` or a keyboard interrupt.
|
|
156
|
+
All workers finish the tasks they acquired and acknowledge them. This includes the tasks in their prefetch buffer.
|
|
157
|
+
A hard kill cannot be intercepted, so the lease reaper collects the buffered tasks after the lease expires.
|
|
134
158
|
|
|
135
159
|
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
136
160
|
which might be useful for draining a one-off queue.
|
|
@@ -143,27 +167,36 @@ The optional TUI inspector lets you watch queues, tasks, and task details in rea
|
|
|
143
167
|
Install it with the `inspector` extra and launch it from a separate terminal:
|
|
144
168
|
|
|
145
169
|
```console
|
|
146
|
-
uv add threadmill[inspector]
|
|
170
|
+
uv add "threadmill[inspector]"
|
|
147
171
|
uv run manage.py threadmill inspector
|
|
148
172
|
```
|
|
149
173
|
|
|
150
174
|
### Redis Backend Options
|
|
151
175
|
|
|
176
|
+
> [!IMPORTANT]
|
|
177
|
+
> Threadmill requires a persistent Redis without eviction.
|
|
178
|
+
|
|
152
179
|
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
153
180
|
`TASKS` configuration:
|
|
154
181
|
|
|
155
|
-
| Option | Default | Description
|
|
156
|
-
| ------------------- | ------------------------- |
|
|
157
|
-
| `lease_ttl` | `timedelta(hours=1)` | Max
|
|
158
|
-
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal.
|
|
159
|
-
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes.
|
|
160
|
-
| `batch_size` | `100` | Max tasks to move or
|
|
161
|
-
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll.
|
|
162
|
-
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts.
|
|
163
|
-
|
|
164
|
-
A task
|
|
165
|
-
|
|
166
|
-
|
|
182
|
+
| Option | Default | Description |
|
|
183
|
+
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
|
|
184
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max time from acquisition to acknowledgement before the task is retried or marked FAILED. |
|
|
185
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
186
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
187
|
+
| `batch_size` | `100` | Max tasks to move or reap per broker pass. |
|
|
188
|
+
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll. |
|
|
189
|
+
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts. |
|
|
190
|
+
|
|
191
|
+
A task whose lease expired reaches the `retry` callback as an
|
|
192
|
+
`AcknowledgementTimeout` error, or is marked FAILED when nothing retries it.
|
|
193
|
+
A claimed task whose stored payload cannot be read any more is dropped with the
|
|
194
|
+
read error logged. A dropped task records no result, so it leaves the inspector
|
|
195
|
+
and cannot be requeued.
|
|
196
|
+
Keep `lease_ttl` above your worst-case runtime and above the time a task waits in a
|
|
197
|
+
prefetch buffer. A task that outlives its lease can still run, so a retry can run
|
|
198
|
+
at the same time. The acknowledgement of the lease holder wins. The late result
|
|
199
|
+
of an expired attempt is discarded.
|
|
167
200
|
|
|
168
201
|
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
169
202
|
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
@@ -177,8 +210,8 @@ The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
|
177
210
|
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
178
211
|
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
179
212
|
|
|
180
|
-
|
|
181
|
-
|
|
213
|
+
Failed tasks are re-queued preserving their ID and error history; the broker
|
|
214
|
+
promotes them back to the ready queue once the delay elapses.
|
|
182
215
|
|
|
183
216
|
#### Built-in `ExponentialBackoff`
|
|
184
217
|
|
|
@@ -193,6 +226,7 @@ import datetime
|
|
|
193
226
|
from django.tasks import task
|
|
194
227
|
from requests import HTTPError
|
|
195
228
|
|
|
229
|
+
from threadmill.exceptions import AcknowledgementTimeout
|
|
196
230
|
from threadmill.retry import ExponentialBackoff
|
|
197
231
|
|
|
198
232
|
|
|
@@ -202,7 +236,7 @@ from threadmill.retry import ExponentialBackoff
|
|
|
202
236
|
max_delay=datetime.timedelta(minutes=5),
|
|
203
237
|
factor=2.0,
|
|
204
238
|
max_retries=5,
|
|
205
|
-
expected_exceptions=(HTTPError,),
|
|
239
|
+
expected_exceptions=(HTTPError, AcknowledgementTimeout),
|
|
206
240
|
)
|
|
207
241
|
)
|
|
208
242
|
def fetch_github_api(url: str): ...
|
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
<a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
# Threadmill [](https://pypi.python.org/pypi/threadmill/) [](https://codecov.io/gh/codingjoe/threadmill) [](https://raw.githubusercontent.com/codingjoe/threadmill/main/LICENSE)
|
|
15
15
|
|
|
16
16
|
**Durable high-performance backend for Django's task framework.**
|
|
17
17
|
|
|
18
|
+
<p align="center">
|
|
19
|
+
<picture>
|
|
20
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-dark.svg">
|
|
21
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-light.svg">
|
|
22
|
+
<img alt="Tasks per second with one worker process each: Threadmill 19,954, Threadmill (GIL) 9,215, dramatiq 7,251, huey 6,254, celery 2,497, django-tasks-db 1,814, django-tasks-rq 84." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/backend-comparison-light.svg">
|
|
23
|
+
</picture>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
18
26
|
## Design Principles
|
|
19
27
|
|
|
20
28
|
- **Durability** – Recover from any failures, even poorly written tasks.
|
|
@@ -26,7 +34,7 @@
|
|
|
26
34
|
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
27
35
|
|
|
28
36
|
```console
|
|
29
|
-
uv add threadmill[redis]
|
|
37
|
+
uv add "threadmill[redis]"
|
|
30
38
|
```
|
|
31
39
|
|
|
32
40
|
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
@@ -53,7 +61,7 @@ TASKS = {
|
|
|
53
61
|
Optionally, install the inspector dependency if you want the TUI:
|
|
54
62
|
|
|
55
63
|
```console
|
|
56
|
-
uv add threadmill[inspector]
|
|
64
|
+
uv add "threadmill[inspector]"
|
|
57
65
|
```
|
|
58
66
|
|
|
59
67
|
Then launch the worker pool:
|
|
@@ -70,13 +78,24 @@ The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
|
70
78
|
|
|
71
79
|
#### Utilization
|
|
72
80
|
|
|
73
|
-
Depending on your workload, you can tweak the number of processes and threads.
|
|
74
|
-
|
|
81
|
+
Depending on your workload, you can tweak the number of worker processes and the number of threads per process. Processes always run in parallel, because each one has its own interpreter. Threads share memory, so they are cheap, but whether they run in parallel depends on your interpreter:
|
|
82
|
+
|
|
83
|
+
- On a regular GIL build, Python runs in one thread at a time. Threads still overlap waiting for IO, but CPU-bound tasks do not speed up. Raising the thread count far above the queue depth can also leave processes idle, because each process prefetches a batch sized by its thread count.
|
|
84
|
+
- On a [free-threaded build](https://peps.python.org/pep-0779/), threads run truly in parallel, so CPU-bound tasks scale across cores without the cost of extra processes.
|
|
85
|
+
|
|
86
|
+
A pool on a free-threaded interpreter therefore reaches the same throughput with one process running many threads as with many processes running one thread each, while using a single Redis connection pool and one copy of your application state:
|
|
75
87
|
|
|
76
88
|
```console
|
|
77
|
-
uv run manage.py threadmill worker --
|
|
89
|
+
uv run manage.py threadmill worker --workers 1 --threads 8
|
|
78
90
|
```
|
|
79
91
|
|
|
92
|
+
Threads all live in one process, so a crash or a `--max-tasks` recycle takes every thread down at once. One process per core, the default, confines that to a single process. Prefer threads when memory matters more than that isolation, and keep the default when it does not.
|
|
93
|
+
|
|
94
|
+
A free-threaded interpreter can be slower than a regular one for single-threaded work, so a GIL build stays the better choice for IO-bound tasks. Pick the interpreter that fits your workload rather than assuming free threading is an upgrade.
|
|
95
|
+
|
|
96
|
+
> [!WARNING]
|
|
97
|
+
> A C extension that does not declare free-threading support re-enables the GIL for the whole process, which silently costs you all thread parallelism. `hiredis` is a common offender: installing `redis[hiredis]` enables it on import. The worker logs a warning when it detects that the GIL was re-enabled.
|
|
98
|
+
|
|
80
99
|
#### Health
|
|
81
100
|
|
|
82
101
|
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
@@ -87,12 +106,15 @@ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
|
|
|
87
106
|
|
|
88
107
|
This will restart the workers after 1000 tasks have been processed, with a random jitter of up to 100 tasks to avoid all workers restarting at the same time.
|
|
89
108
|
|
|
109
|
+
The limit is soft. A worker drains its buffer and the batch in hand before it stops. It can then run about twice `--prefetch-count` tasks more than the configured maximum.
|
|
110
|
+
|
|
90
111
|
Should a worker crash or be killed, the pool will automatically restart it.
|
|
91
112
|
|
|
92
113
|
#### Shutdown
|
|
93
114
|
|
|
94
|
-
A graceful shutdown is possible with
|
|
95
|
-
All workers
|
|
115
|
+
A graceful shutdown is possible with `SIGTERM` or a keyboard interrupt.
|
|
116
|
+
All workers finish the tasks they acquired and acknowledge them. This includes the tasks in their prefetch buffer.
|
|
117
|
+
A hard kill cannot be intercepted, so the lease reaper collects the buffered tasks after the lease expires.
|
|
96
118
|
|
|
97
119
|
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
98
120
|
which might be useful for draining a one-off queue.
|
|
@@ -105,27 +127,36 @@ The optional TUI inspector lets you watch queues, tasks, and task details in rea
|
|
|
105
127
|
Install it with the `inspector` extra and launch it from a separate terminal:
|
|
106
128
|
|
|
107
129
|
```console
|
|
108
|
-
uv add threadmill[inspector]
|
|
130
|
+
uv add "threadmill[inspector]"
|
|
109
131
|
uv run manage.py threadmill inspector
|
|
110
132
|
```
|
|
111
133
|
|
|
112
134
|
### Redis Backend Options
|
|
113
135
|
|
|
136
|
+
> [!IMPORTANT]
|
|
137
|
+
> Threadmill requires a persistent Redis without eviction.
|
|
138
|
+
|
|
114
139
|
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
115
140
|
`TASKS` configuration:
|
|
116
141
|
|
|
117
|
-
| Option | Default | Description
|
|
118
|
-
| ------------------- | ------------------------- |
|
|
119
|
-
| `lease_ttl` | `timedelta(hours=1)` | Max
|
|
120
|
-
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal.
|
|
121
|
-
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes.
|
|
122
|
-
| `batch_size` | `100` | Max tasks to move or
|
|
123
|
-
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll.
|
|
124
|
-
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts.
|
|
125
|
-
|
|
126
|
-
A task
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
| Option | Default | Description |
|
|
143
|
+
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
|
|
144
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max time from acquisition to acknowledgement before the task is retried or marked FAILED. |
|
|
145
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
146
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
147
|
+
| `batch_size` | `100` | Max tasks to move or reap per broker pass. |
|
|
148
|
+
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll. |
|
|
149
|
+
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts. |
|
|
150
|
+
|
|
151
|
+
A task whose lease expired reaches the `retry` callback as an
|
|
152
|
+
`AcknowledgementTimeout` error, or is marked FAILED when nothing retries it.
|
|
153
|
+
A claimed task whose stored payload cannot be read any more is dropped with the
|
|
154
|
+
read error logged. A dropped task records no result, so it leaves the inspector
|
|
155
|
+
and cannot be requeued.
|
|
156
|
+
Keep `lease_ttl` above your worst-case runtime and above the time a task waits in a
|
|
157
|
+
prefetch buffer. A task that outlives its lease can still run, so a retry can run
|
|
158
|
+
at the same time. The acknowledgement of the lease holder wins. The late result
|
|
159
|
+
of an expired attempt is discarded.
|
|
129
160
|
|
|
130
161
|
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
131
162
|
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
@@ -139,8 +170,8 @@ The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
|
139
170
|
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
140
171
|
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
141
172
|
|
|
142
|
-
|
|
143
|
-
|
|
173
|
+
Failed tasks are re-queued preserving their ID and error history; the broker
|
|
174
|
+
promotes them back to the ready queue once the delay elapses.
|
|
144
175
|
|
|
145
176
|
#### Built-in `ExponentialBackoff`
|
|
146
177
|
|
|
@@ -155,6 +186,7 @@ import datetime
|
|
|
155
186
|
from django.tasks import task
|
|
156
187
|
from requests import HTTPError
|
|
157
188
|
|
|
189
|
+
from threadmill.exceptions import AcknowledgementTimeout
|
|
158
190
|
from threadmill.retry import ExponentialBackoff
|
|
159
191
|
|
|
160
192
|
|
|
@@ -164,7 +196,7 @@ from threadmill.retry import ExponentialBackoff
|
|
|
164
196
|
max_delay=datetime.timedelta(minutes=5),
|
|
165
197
|
factor=2.0,
|
|
166
198
|
max_retries=5,
|
|
167
|
-
expected_exceptions=(HTTPError,),
|
|
199
|
+
expected_exceptions=(HTTPError, AcknowledgementTimeout),
|
|
168
200
|
)
|
|
169
201
|
)
|
|
170
202
|
def fetch_github_api(url: str): ...
|
|
@@ -25,6 +25,8 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python :: 3 :: Only",
|
|
26
26
|
"Programming Language :: Python :: 3.14",
|
|
27
27
|
"Programming Language :: Python :: 3.15",
|
|
28
|
+
"Programming Language :: Python :: Free Threading",
|
|
29
|
+
"Programming Language :: Python :: Free Threading :: 3 - Stable",
|
|
28
30
|
"Topic :: Communications :: Email",
|
|
29
31
|
"Topic :: Software Development",
|
|
30
32
|
"Topic :: Text Processing :: Markup :: Markdown",
|
|
@@ -49,6 +51,11 @@ dev = [
|
|
|
49
51
|
{ include-group = "test" },
|
|
50
52
|
]
|
|
51
53
|
test = [
|
|
54
|
+
"celery",
|
|
55
|
+
"django-tasks-db",
|
|
56
|
+
"django-tasks-rq",
|
|
57
|
+
"dramatiq[redis]>=2.2.1",
|
|
58
|
+
"huey",
|
|
52
59
|
"pytest",
|
|
53
60
|
"pytest-asyncio",
|
|
54
61
|
"pytest-benchmark",
|
|
@@ -64,7 +71,7 @@ write_to = "threadmill/_version.py"
|
|
|
64
71
|
module.name = "threadmill"
|
|
65
72
|
|
|
66
73
|
[tool.ruff]
|
|
67
|
-
src = [ "tests", "threadmill" ]
|
|
74
|
+
src = [ "benchmarks", "tests", "threadmill" ]
|
|
68
75
|
lint.select = [
|
|
69
76
|
"B", # flake8-bugbear
|
|
70
77
|
"C", # flake8-comprehensions
|
|
@@ -86,7 +93,7 @@ lint.pydocstyle.convention = "pep257"
|
|
|
86
93
|
|
|
87
94
|
[tool.pytest]
|
|
88
95
|
ini_options.minversion = "6.1"
|
|
89
|
-
ini_options.testpaths = [ "tests" ]
|
|
96
|
+
ini_options.testpaths = [ "benchmarks", "tests" ]
|
|
90
97
|
ini_options.addopts = """\
|
|
91
98
|
--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname \
|
|
92
99
|
--benchmark-min-rounds=10\
|
|
@@ -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.8.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gd8270af5a'
|
|
@@ -3,9 +3,11 @@ import dataclasses
|
|
|
3
3
|
import datetime
|
|
4
4
|
import enum
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import threading
|
|
7
8
|
import typing
|
|
8
9
|
from abc import ABC
|
|
10
|
+
from traceback import format_exception
|
|
9
11
|
|
|
10
12
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
11
13
|
from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
|
|
@@ -15,6 +17,8 @@ from django.tasks.exceptions import InvalidTask
|
|
|
15
17
|
from django.utils.inspect import is_module_level_function
|
|
16
18
|
from django.utils.module_loading import import_string
|
|
17
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
18
22
|
|
|
19
23
|
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
20
24
|
class RetryTask(Task):
|
|
@@ -61,6 +65,24 @@ class RetryTask(Task):
|
|
|
61
65
|
return (reconstructor, (kwargs,))
|
|
62
66
|
|
|
63
67
|
|
|
68
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
69
|
+
class ThreadmillTaskResult(TaskResult):
|
|
70
|
+
lease_token: str | None = None
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_result(
|
|
74
|
+
cls, task_result: TaskResult, *, lease_token: str | None
|
|
75
|
+
) -> ThreadmillTaskResult:
|
|
76
|
+
return cls(
|
|
77
|
+
**{
|
|
78
|
+
field.name: getattr(task_result, field.name)
|
|
79
|
+
for field in dataclasses.fields(TaskResult)
|
|
80
|
+
if field.init
|
|
81
|
+
},
|
|
82
|
+
lease_token=lease_token,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
64
86
|
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
65
87
|
class QueueCounts:
|
|
66
88
|
"""Point-in-time cardinality of each queue segment."""
|
|
@@ -151,10 +173,15 @@ class TaskResultEncoder(DjangoJSONEncoder):
|
|
|
151
173
|
"""JSON encoder for TaskResult and TaskError objects."""
|
|
152
174
|
|
|
153
175
|
def default(self, o):
|
|
154
|
-
if isinstance(o,
|
|
176
|
+
if isinstance(o, TaskResult):
|
|
177
|
+
return {
|
|
178
|
+
field.name: getattr(o, field.name)
|
|
179
|
+
for field in dataclasses.fields(TaskResult)
|
|
180
|
+
}
|
|
181
|
+
if isinstance(o, TaskError):
|
|
155
182
|
return {
|
|
156
183
|
field.name: getattr(o, field.name)
|
|
157
|
-
for field in dataclasses.fields(
|
|
184
|
+
for field in dataclasses.fields(TaskError)
|
|
158
185
|
}
|
|
159
186
|
if isinstance(o, RetryTask):
|
|
160
187
|
data = {
|
|
@@ -208,23 +235,53 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
208
235
|
"Task's retry function must be defined at a module level or be a deconstructible callable."
|
|
209
236
|
)
|
|
210
237
|
|
|
238
|
+
@staticmethod
|
|
239
|
+
def create_task_error(exception: BaseException) -> TaskError:
|
|
240
|
+
exception_type = type(exception)
|
|
241
|
+
return TaskError(
|
|
242
|
+
exception_class_path=f"{exception_type.__module__}.{exception_type.__qualname__}",
|
|
243
|
+
traceback="".join(format_exception(exception)),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def retry_delay(task_result: TaskResult) -> datetime.timedelta | None:
|
|
248
|
+
if task_result.task.retry:
|
|
249
|
+
try:
|
|
250
|
+
return task_result.task.retry(TaskContext(task_result=task_result))
|
|
251
|
+
except Exception:
|
|
252
|
+
logger.exception(
|
|
253
|
+
"Retry callback failed for task '%s@%s'",
|
|
254
|
+
task_result.id,
|
|
255
|
+
task_result.task.module_path,
|
|
256
|
+
)
|
|
257
|
+
|
|
211
258
|
def acquire(
|
|
212
259
|
self,
|
|
213
260
|
*queue_names: str,
|
|
261
|
+
count: int = 1,
|
|
214
262
|
timeout: datetime.timedelta | None = None,
|
|
215
263
|
worker: str = "",
|
|
216
|
-
) ->
|
|
264
|
+
) -> list[ThreadmillTaskResult]:
|
|
217
265
|
"""
|
|
218
|
-
Return and lock
|
|
266
|
+
Return and lock up to `count` tasks. The tasks stay in the queue.
|
|
267
|
+
|
|
268
|
+
The method waits up to `timeout` for the first task. It fills the
|
|
269
|
+
remaining tasks without a wait.
|
|
219
270
|
|
|
220
271
|
Args:
|
|
221
272
|
queue_names: The names of the queues to acquire tasks from.
|
|
222
|
-
|
|
223
|
-
|
|
273
|
+
count: The maximum number of tasks to acquire, at least 1.
|
|
274
|
+
timeout: The maximum time to wait for the first task. If None, the method waits without a limit.
|
|
275
|
+
worker: The name of the worker thread that acquires the tasks.
|
|
224
276
|
|
|
225
277
|
Raises:
|
|
226
|
-
TimeoutError:
|
|
227
|
-
queue.Empty:
|
|
278
|
+
TimeoutError: The first task does not arrive within the specified timeout.
|
|
279
|
+
queue.Empty: The first task is unavailable and the timeout is None.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Between one and `count` leased task results in lease order. The list is
|
|
283
|
+
never empty. Each result is a `ThreadmillTaskResult` that carries the
|
|
284
|
+
lease token for its acknowledgement.
|
|
228
285
|
"""
|
|
229
286
|
raise NotImplementedError
|
|
230
287
|
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
-- ARGV[7] -- queue name
|
|
21
21
|
-- Returns: 1 on success, 0 if task was not in the running set
|
|
22
22
|
|
|
23
|
+
local lease_token = redis.call('HGET', KEYS[3], 'lease_token')
|
|
24
|
+
if lease_token and lease_token ~= ARGV[8] then
|
|
25
|
+
return 0
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
24
29
|
if removed == 0 then
|
|
25
30
|
return 0 -- Task already reaped, skip
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- Lease up to ARGV[8] ready tasks for the calling worker in one round-trip:
|
|
2
|
+
-- each task's stored payload comes back beside the lease token stamped for it.
|
|
3
|
+
-- Queues are tried round-robin from ARGV[7], one queue per pop, so a batch
|
|
4
|
+
-- spreads across queues instead of draining one and a backlogged queue cannot
|
|
5
|
+
-- starve its neighbours.
|
|
6
|
+
--
|
|
7
|
+
-- The payload comes back as it was enqueued. Apply the lease stamped beside it
|
|
8
|
+
-- (lease_worker, lease_started_at, lease_token) to report the task as RUNNING.
|
|
9
|
+
--
|
|
10
|
+
-- KEYS[1..N] -- interleaved running keys and queue keys, one pair per queue:
|
|
11
|
+
-- KEYS[1] = running set, KEYS[2] = queue set, KEYS[3] = running,
|
|
12
|
+
-- KEYS[4] = queue, etc.
|
|
13
|
+
-- ARGV[1] -- current time in milliseconds
|
|
14
|
+
-- ARGV[2] -- current time as ISO-8601 string, stamped as lease_started_at
|
|
15
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:task:")
|
|
16
|
+
-- ARGV[4] -- number of queue pairs (N/2)
|
|
17
|
+
-- ARGV[5] -- worker name, stamped as lease_worker
|
|
18
|
+
-- ARGV[6] -- lease TTL in milliseconds
|
|
19
|
+
-- ARGV[7] -- start_index; 0-based index of the queue pair to scan first, so
|
|
20
|
+
-- start_index 0 is the pair at KEYS[1] and KEYS[2]
|
|
21
|
+
-- ARGV[8] -- maximum number of tasks to lease
|
|
22
|
+
-- Returns: array of {payload, lease_token} pairs, empty when no queue yields a
|
|
23
|
+
-- task. A queue whose head entry holds no payload yields nothing for that pop
|
|
24
|
+
-- and the scan moves on.
|
|
25
|
+
|
|
26
|
+
local num_queues = tonumber(ARGV[4])
|
|
27
|
+
local lease_ttl_ms = tonumber(ARGV[6])
|
|
28
|
+
local start_index = tonumber(ARGV[7])
|
|
29
|
+
local max_count = tonumber(ARGV[8])
|
|
30
|
+
local task_key_prefix = ARGV[3]
|
|
31
|
+
local now_ms = tonumber(ARGV[1])
|
|
32
|
+
local tasks = {}
|
|
33
|
+
local misses = 0
|
|
34
|
+
local queue_index = start_index % num_queues + 1
|
|
35
|
+
-- joe: one full round without a task ends the scan, so a queue whose head
|
|
36
|
+
-- entry is a ghost yields a short batch; retry the queue if ghosts ever dominate
|
|
37
|
+
while #tasks < max_count and misses < num_queues do
|
|
38
|
+
local popped = redis.call('ZPOPMIN', KEYS[queue_index * 2])
|
|
39
|
+
local data = #popped > 0
|
|
40
|
+
and redis.call('HGET', task_key_prefix .. popped[1], 'data')
|
|
41
|
+
if data then
|
|
42
|
+
local lease_token = string.format(
|
|
43
|
+
'%06x%06x%06x%06x',
|
|
44
|
+
math.random(0, 0xffffff), math.random(0, 0xffffff),
|
|
45
|
+
math.random(0, 0xffffff), math.random(0, 0xffffff))
|
|
46
|
+
local deadline = now_ms + lease_ttl_ms
|
|
47
|
+
redis.call('ZADD', KEYS[queue_index * 2 - 1], deadline, popped[1])
|
|
48
|
+
redis.call('HSET', task_key_prefix .. popped[1],
|
|
49
|
+
'lease_worker', ARGV[5],
|
|
50
|
+
'lease_started_at', ARGV[2],
|
|
51
|
+
'lease_token', lease_token)
|
|
52
|
+
table.insert(tasks, { data, lease_token })
|
|
53
|
+
end
|
|
54
|
+
misses = data and 0 or (misses + 1)
|
|
55
|
+
queue_index = queue_index % num_queues + 1
|
|
56
|
+
end
|
|
57
|
+
return tasks
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Claim expired tasks for the broker to decide. Undecided claims are picked up
|
|
2
|
+
-- again, and running entries without task data are dropped.
|
|
3
|
+
--
|
|
4
|
+
-- KEYS[1] -- running set (ZSET, scored by lease deadline in milliseconds)
|
|
5
|
+
-- ARGV[1] -- claim TTL in milliseconds
|
|
6
|
+
-- ARGV[2] -- maximum number of tasks to claim per call (batch size)
|
|
7
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
|
|
8
|
+
-- Returns: list of claimed task IDs
|
|
9
|
+
|
|
10
|
+
local clock = redis.call('TIME')
|
|
11
|
+
local now_ms = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
|
12
|
+
local claim_deadline_ms = now_ms + tonumber(ARGV[1])
|
|
13
|
+
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, now_ms, 'LIMIT', 0, tonumber(ARGV[2]))
|
|
14
|
+
local claimed = {}
|
|
15
|
+
for _, task_id in ipairs(expired) do
|
|
16
|
+
if redis.call('EXISTS', ARGV[3] .. task_id) == 1 then
|
|
17
|
+
redis.call('ZADD', KEYS[1], claim_deadline_ms, task_id)
|
|
18
|
+
table.insert(claimed, task_id)
|
|
19
|
+
else
|
|
20
|
+
redis.call('ZREM', KEYS[1], task_id)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
return claimed
|