threadmill 0.7.3__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {threadmill-0.7.3 → threadmill-0.8.1}/PKG-INFO +60 -25
- {threadmill-0.7.3 → threadmill-0.8.1}/README.md +57 -24
- {threadmill-0.7.3 → threadmill-0.8.1}/pyproject.toml +53 -13
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/_version.py +3 -3
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/base.py +82 -15
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/lua/acknowledge.lua +5 -0
- threadmill-0.8.1/threadmill/backends/lua/acquire.lua +57 -0
- threadmill-0.8.1/threadmill/backends/lua/reaper.lua +23 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/redis.py +247 -70
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/exceptions.py +1 -1
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/executor.py +129 -45
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/app.py +8 -12
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/commands/threadmill.py +28 -13
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/retry.py +8 -2
- threadmill-0.7.3/threadmill/backends/lua/acquire.lua +0 -49
- threadmill-0.7.3/threadmill/backends/lua/reaper.lua +0 -40
- {threadmill-0.7.3 → threadmill-0.8.1}/LICENSE +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/__init__.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/apps.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/inspector.scss +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/screens.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/telemetry.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/__init__.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.7.3 → threadmill-0.8.1}/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.1
|
|
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,37 @@ 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
|
|
156
|
-
|
|
|
157
|
-
| `lease_ttl`
|
|
158
|
-
| `result_ttl`
|
|
159
|
-
| `broker_interval`
|
|
160
|
-
| `batch_size`
|
|
161
|
-
| `poll_interval`
|
|
162
|
-
| `poll_max_interval`
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
| `redis_connection_retries` | `3` | Retries per command when its Redis connection fails before the error surfaces. |
|
|
191
|
+
|
|
192
|
+
A task whose lease expired reaches the `retry` callback as an
|
|
193
|
+
`AcknowledgementTimeoutError` error, or is marked FAILED when nothing retries it.
|
|
194
|
+
A claimed task whose stored payload cannot be read any more is dropped with the
|
|
195
|
+
read error logged. A dropped task records no result, so it leaves the inspector
|
|
196
|
+
and cannot be requeued.
|
|
197
|
+
Keep `lease_ttl` above your worst-case runtime and above the time a task waits in a
|
|
198
|
+
prefetch buffer. A task that outlives its lease can still run, so a retry can run
|
|
199
|
+
at the same time. The acknowledgement of the lease holder wins. The late result
|
|
200
|
+
of an expired attempt is discarded.
|
|
167
201
|
|
|
168
202
|
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
169
203
|
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
@@ -177,8 +211,8 @@ The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
|
177
211
|
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
178
212
|
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
179
213
|
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
Failed tasks are re-queued preserving their ID and error history; the broker
|
|
215
|
+
promotes them back to the ready queue once the delay elapses.
|
|
182
216
|
|
|
183
217
|
#### Built-in `ExponentialBackoff`
|
|
184
218
|
|
|
@@ -193,6 +227,7 @@ import datetime
|
|
|
193
227
|
from django.tasks import task
|
|
194
228
|
from requests import HTTPError
|
|
195
229
|
|
|
230
|
+
from threadmill.exceptions import AcknowledgementTimeoutError
|
|
196
231
|
from threadmill.retry import ExponentialBackoff
|
|
197
232
|
|
|
198
233
|
|
|
@@ -202,7 +237,7 @@ from threadmill.retry import ExponentialBackoff
|
|
|
202
237
|
max_delay=datetime.timedelta(minutes=5),
|
|
203
238
|
factor=2.0,
|
|
204
239
|
max_retries=5,
|
|
205
|
-
expected_exceptions=(HTTPError,),
|
|
240
|
+
expected_exceptions=(HTTPError, AcknowledgementTimeoutError),
|
|
206
241
|
)
|
|
207
242
|
)
|
|
208
243
|
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,37 @@ 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
|
|
118
|
-
|
|
|
119
|
-
| `lease_ttl`
|
|
120
|
-
| `result_ttl`
|
|
121
|
-
| `broker_interval`
|
|
122
|
-
| `batch_size`
|
|
123
|
-
| `poll_interval`
|
|
124
|
-
| `poll_max_interval`
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
| `redis_connection_retries` | `3` | Retries per command when its Redis connection fails before the error surfaces. |
|
|
151
|
+
|
|
152
|
+
A task whose lease expired reaches the `retry` callback as an
|
|
153
|
+
`AcknowledgementTimeoutError` error, or is marked FAILED when nothing retries it.
|
|
154
|
+
A claimed task whose stored payload cannot be read any more is dropped with the
|
|
155
|
+
read error logged. A dropped task records no result, so it leaves the inspector
|
|
156
|
+
and cannot be requeued.
|
|
157
|
+
Keep `lease_ttl` above your worst-case runtime and above the time a task waits in a
|
|
158
|
+
prefetch buffer. A task that outlives its lease can still run, so a retry can run
|
|
159
|
+
at the same time. The acknowledgement of the lease holder wins. The late result
|
|
160
|
+
of an expired attempt is discarded.
|
|
129
161
|
|
|
130
162
|
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
131
163
|
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
@@ -139,8 +171,8 @@ The callback receives a `TaskContext` — use `context.attempt` for the current
|
|
|
139
171
|
attempt count and `context.task_result.errors[-1]` for the latest error.
|
|
140
172
|
Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
|
|
141
173
|
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
Failed tasks are re-queued preserving their ID and error history; the broker
|
|
175
|
+
promotes them back to the ready queue once the delay elapses.
|
|
144
176
|
|
|
145
177
|
#### Built-in `ExponentialBackoff`
|
|
146
178
|
|
|
@@ -155,6 +187,7 @@ import datetime
|
|
|
155
187
|
from django.tasks import task
|
|
156
188
|
from requests import HTTPError
|
|
157
189
|
|
|
190
|
+
from threadmill.exceptions import AcknowledgementTimeoutError
|
|
158
191
|
from threadmill.retry import ExponentialBackoff
|
|
159
192
|
|
|
160
193
|
|
|
@@ -164,7 +197,7 @@ from threadmill.retry import ExponentialBackoff
|
|
|
164
197
|
max_delay=datetime.timedelta(minutes=5),
|
|
165
198
|
factor=2.0,
|
|
166
199
|
max_retries=5,
|
|
167
|
-
expected_exceptions=(HTTPError,),
|
|
200
|
+
expected_exceptions=(HTTPError, AcknowledgementTimeoutError),
|
|
168
201
|
)
|
|
169
202
|
)
|
|
170
203
|
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,19 +71,52 @@ write_to = "threadmill/_version.py"
|
|
|
64
71
|
module.name = "threadmill"
|
|
65
72
|
|
|
66
73
|
[tool.ruff]
|
|
67
|
-
src = [ "tests", "threadmill" ]
|
|
68
|
-
lint.select = [
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
74
|
+
src = [ "benchmarks", "tests", "threadmill" ]
|
|
75
|
+
lint.extend-select = [
|
|
76
|
+
"C90",
|
|
77
|
+
"D",
|
|
78
|
+
"D213",
|
|
79
|
+
"DJ",
|
|
80
|
+
"E",
|
|
81
|
+
"G",
|
|
82
|
+
"N",
|
|
83
|
+
"PERF",
|
|
84
|
+
"PT",
|
|
85
|
+
"PTH",
|
|
86
|
+
"RET",
|
|
87
|
+
"S",
|
|
88
|
+
"SIM",
|
|
89
|
+
"SLOT",
|
|
90
|
+
"T20",
|
|
91
|
+
"TRY",
|
|
92
|
+
"UP",
|
|
93
|
+
"W",
|
|
94
|
+
]
|
|
95
|
+
lint.ignore = [ "D1", "D203", "D212", "E501", "RUF01" ]
|
|
96
|
+
lint.per-file-ignores."benchmarks/**" = [
|
|
97
|
+
"DJ006",
|
|
98
|
+
"DJ007",
|
|
99
|
+
"DJ008",
|
|
100
|
+
"PLR2004",
|
|
101
|
+
"S101",
|
|
102
|
+
"S105",
|
|
103
|
+
"S106",
|
|
104
|
+
"S311",
|
|
105
|
+
"S603",
|
|
106
|
+
"S607"
|
|
107
|
+
]
|
|
108
|
+
lint.per-file-ignores."tests/**" = [
|
|
109
|
+
"DJ006",
|
|
110
|
+
"DJ007",
|
|
111
|
+
"DJ008",
|
|
112
|
+
"PLR2004",
|
|
113
|
+
"S101",
|
|
114
|
+
"S105",
|
|
115
|
+
"S106",
|
|
116
|
+
"S311",
|
|
117
|
+
"S603",
|
|
118
|
+
"S607"
|
|
78
119
|
]
|
|
79
|
-
lint.ignore = [ "B904", "D1", "E501", "S101" ]
|
|
80
120
|
lint.isort.combine-as-imports = true
|
|
81
121
|
lint.isort.force-wrap-aliases = true
|
|
82
122
|
lint.isort.known-first-party = [ "threadmill", "tests" ]
|
|
@@ -86,7 +126,7 @@ lint.pydocstyle.convention = "pep257"
|
|
|
86
126
|
|
|
87
127
|
[tool.pytest]
|
|
88
128
|
ini_options.minversion = "6.1"
|
|
89
|
-
ini_options.testpaths = [ "tests" ]
|
|
129
|
+
ini_options.testpaths = [ "benchmarks", "tests" ]
|
|
90
130
|
ini_options.addopts = """\
|
|
91
131
|
--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname \
|
|
92
132
|
--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.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g9c8ce3fb9'
|
|
@@ -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,10 +17,13 @@ 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):
|
|
21
|
-
"""
|
|
25
|
+
"""
|
|
26
|
+
Task with an optional retry callback for backoff scheduling.
|
|
22
27
|
|
|
23
28
|
Usage:
|
|
24
29
|
|
|
@@ -43,6 +48,7 @@ class RetryTask(Task):
|
|
|
43
48
|
if hasattr(self.retry, "deconstruct"):
|
|
44
49
|
return self.retry.deconstruct()
|
|
45
50
|
return f"{self.retry.__module__}.{self.retry.__qualname__}"
|
|
51
|
+
return None
|
|
46
52
|
|
|
47
53
|
@classmethod
|
|
48
54
|
def _reconstruct(cls, kwargs):
|
|
@@ -61,6 +67,24 @@ class RetryTask(Task):
|
|
|
61
67
|
return (reconstructor, (kwargs,))
|
|
62
68
|
|
|
63
69
|
|
|
70
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
71
|
+
class ThreadmillTaskResult(TaskResult):
|
|
72
|
+
lease_token: str | None = None
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_result(
|
|
76
|
+
cls, task_result: TaskResult, *, lease_token: str | None
|
|
77
|
+
) -> ThreadmillTaskResult:
|
|
78
|
+
return cls(
|
|
79
|
+
**{
|
|
80
|
+
field.name: getattr(task_result, field.name)
|
|
81
|
+
for field in dataclasses.fields(TaskResult)
|
|
82
|
+
if field.init
|
|
83
|
+
},
|
|
84
|
+
lease_token=lease_token,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
64
88
|
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
65
89
|
class QueueCounts:
|
|
66
90
|
"""Point-in-time cardinality of each queue segment."""
|
|
@@ -151,18 +175,22 @@ class TaskResultEncoder(DjangoJSONEncoder):
|
|
|
151
175
|
"""JSON encoder for TaskResult and TaskError objects."""
|
|
152
176
|
|
|
153
177
|
def default(self, o):
|
|
154
|
-
if isinstance(o,
|
|
178
|
+
if isinstance(o, TaskResult):
|
|
155
179
|
return {
|
|
156
180
|
field.name: getattr(o, field.name)
|
|
157
|
-
for field in dataclasses.fields(
|
|
181
|
+
for field in dataclasses.fields(TaskResult)
|
|
182
|
+
}
|
|
183
|
+
if isinstance(o, TaskError):
|
|
184
|
+
return {
|
|
185
|
+
field.name: getattr(o, field.name)
|
|
186
|
+
for field in dataclasses.fields(TaskError)
|
|
158
187
|
}
|
|
159
188
|
if isinstance(o, RetryTask):
|
|
160
|
-
|
|
189
|
+
return {
|
|
161
190
|
field.name: getattr(o, field.name)
|
|
162
191
|
for field in dataclasses.fields(RetryTask)
|
|
163
192
|
if field.name not in {"func", "retry"}
|
|
164
193
|
} | {"func": o.module_path, "retry": o.retry_path}
|
|
165
|
-
return data
|
|
166
194
|
return super().default(o)
|
|
167
195
|
|
|
168
196
|
|
|
@@ -204,27 +232,60 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
204
232
|
if task.retry is not None and not (
|
|
205
233
|
is_module_level_function(task.retry) or hasattr(task.retry, "deconstruct")
|
|
206
234
|
):
|
|
207
|
-
|
|
208
|
-
"Task's retry function must be defined at a module level or be a
|
|
235
|
+
message = (
|
|
236
|
+
"Task's retry function must be defined at a module level or be a "
|
|
237
|
+
"deconstructible callable."
|
|
209
238
|
)
|
|
239
|
+
raise InvalidTask(message)
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def create_task_error(exception: BaseException) -> TaskError:
|
|
243
|
+
exception_type = type(exception)
|
|
244
|
+
return TaskError(
|
|
245
|
+
exception_class_path=f"{exception_type.__module__}.{exception_type.__qualname__}",
|
|
246
|
+
traceback="".join(format_exception(exception)),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def retry_delay(task_result: TaskResult) -> datetime.timedelta | None:
|
|
251
|
+
if task_result.task.retry:
|
|
252
|
+
try:
|
|
253
|
+
return task_result.task.retry(TaskContext(task_result=task_result))
|
|
254
|
+
except Exception:
|
|
255
|
+
logger.exception(
|
|
256
|
+
"Retry callback failed for task '%s@%s'",
|
|
257
|
+
task_result.id,
|
|
258
|
+
task_result.task.module_path,
|
|
259
|
+
)
|
|
260
|
+
return None
|
|
210
261
|
|
|
211
262
|
def acquire(
|
|
212
263
|
self,
|
|
213
264
|
*queue_names: str,
|
|
265
|
+
count: int = 1,
|
|
214
266
|
timeout: datetime.timedelta | None = None,
|
|
215
267
|
worker: str = "",
|
|
216
|
-
) ->
|
|
268
|
+
) -> list[ThreadmillTaskResult]:
|
|
217
269
|
"""
|
|
218
|
-
Return and lock
|
|
270
|
+
Return and lock up to `count` tasks. The tasks stay in the queue.
|
|
271
|
+
|
|
272
|
+
The method waits up to `timeout` for the first task. It fills the
|
|
273
|
+
remaining tasks without a wait.
|
|
219
274
|
|
|
220
275
|
Args:
|
|
221
276
|
queue_names: The names of the queues to acquire tasks from.
|
|
222
|
-
|
|
223
|
-
|
|
277
|
+
count: The maximum number of tasks to acquire, at least 1.
|
|
278
|
+
timeout: The maximum time to wait for the first task. If None, the method waits without a limit.
|
|
279
|
+
worker: The name of the worker thread that acquires the tasks.
|
|
224
280
|
|
|
225
281
|
Raises:
|
|
226
|
-
TimeoutError:
|
|
227
|
-
queue.Empty:
|
|
282
|
+
TimeoutError: The first task does not arrive within the specified timeout.
|
|
283
|
+
queue.Empty: The first task is unavailable and the timeout is None.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Between one and `count` leased task results in lease order. The list is
|
|
287
|
+
never empty. Each result is a `ThreadmillTaskResult` that carries the
|
|
288
|
+
lease token for its acknowledgement.
|
|
228
289
|
"""
|
|
229
290
|
raise NotImplementedError
|
|
230
291
|
|
|
@@ -233,7 +294,8 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
233
294
|
raise NotImplementedError
|
|
234
295
|
|
|
235
296
|
def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
|
|
236
|
-
"""
|
|
297
|
+
"""
|
|
298
|
+
Re-queue a failed task result for a retry attempt after `run_after`.
|
|
237
299
|
|
|
238
300
|
Cleans up any persisted failed result so the method works both for
|
|
239
301
|
in-flight retries (task still running) and inspector-driven requeues
|
|
@@ -251,6 +313,10 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
251
313
|
"""
|
|
252
314
|
Yield up to `count` tasks from a queue in the given status segment.
|
|
253
315
|
|
|
316
|
+
Pending work comes back in queue order, so the first READY task is the
|
|
317
|
+
next one a worker picks up. Finished results come back most recently
|
|
318
|
+
finished first.
|
|
319
|
+
|
|
254
320
|
Args:
|
|
255
321
|
queue_name: The name of the queue to peek into.
|
|
256
322
|
status: The status of the tasks to yield.
|
|
@@ -275,7 +341,8 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
275
341
|
raise NotImplementedError
|
|
276
342
|
|
|
277
343
|
def purge(self, queue_name: str) -> None:
|
|
278
|
-
"""
|
|
344
|
+
"""
|
|
345
|
+
Delete every task across all segments of a queue.
|
|
279
346
|
|
|
280
347
|
Args:
|
|
281
348
|
queue_name: The queue to purge.
|
|
@@ -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
|