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.
Files changed (28) hide show
  1. {threadmill-0.7.3 → threadmill-0.8.1}/PKG-INFO +60 -25
  2. {threadmill-0.7.3 → threadmill-0.8.1}/README.md +57 -24
  3. {threadmill-0.7.3 → threadmill-0.8.1}/pyproject.toml +53 -13
  4. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/_version.py +3 -3
  5. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/base.py +82 -15
  6. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/lua/acknowledge.lua +5 -0
  7. threadmill-0.8.1/threadmill/backends/lua/acquire.lua +57 -0
  8. threadmill-0.8.1/threadmill/backends/lua/reaper.lua +23 -0
  9. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/redis.py +247 -70
  10. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/exceptions.py +1 -1
  11. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/executor.py +129 -45
  12. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/app.py +8 -12
  13. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/commands/threadmill.py +28 -13
  14. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/retry.py +8 -2
  15. threadmill-0.7.3/threadmill/backends/lua/acquire.lua +0 -49
  16. threadmill-0.7.3/threadmill/backends/lua/reaper.lua +0 -40
  17. {threadmill-0.7.3 → threadmill-0.8.1}/LICENSE +0 -0
  18. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/__init__.py +0 -0
  19. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/apps.py +0 -0
  20. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/__init__.py +0 -0
  21. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/backends/lua/mover.lua +0 -0
  22. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/__init__.py +0 -0
  23. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/inspector.scss +0 -0
  24. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/screens.py +0 -0
  25. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/inspector/telemetry.py +0 -0
  26. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/__init__.py +0 -0
  27. {threadmill-0.7.3 → threadmill-0.8.1}/threadmill/management/commands/__init__.py +0 -0
  28. {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.7.3
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 [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
54
+ # Threadmill [![PyPI Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/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
- Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
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 --processes 4 --threads 2
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 the `SIGTERM` or a keyboard interrupt.
133
- All workers will finish the tasks they acquired and acknowledge them.
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 | Default | Description |
156
- | ------------------- | ------------------------- | ----------------------------------------------------------------------- |
157
- | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
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 requeue per broker pass. |
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 that is started but never acknowledged (lease expired) is marked FAILED
165
- with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
166
- worst-case task runtime.
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
- The worker re-queues the failed task, preserving its ID and error history;
181
- the broker promotes it back to the ready queue once the delay elapses.
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 [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
14
+ # Threadmill [![PyPI Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/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
- Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
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 --processes 4 --threads 2
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 the `SIGTERM` or a keyboard interrupt.
95
- All workers will finish the tasks they acquired and acknowledge them.
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 | Default | Description |
118
- | ------------------- | ------------------------- | ----------------------------------------------------------------------- |
119
- | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
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 requeue per broker pass. |
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 that is started but never acknowledged (lease expired) is marked FAILED
127
- with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
128
- worst-case task runtime.
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
- The worker re-queues the failed task, preserving its ID and error history;
143
- the broker promotes it back to the ready queue once the delay elapses.
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
- "B", # flake8-bugbear
70
- "C", # flake8-comprehensions
71
- "D", # pydocstyle
72
- "E", # pycodestyle errors
73
- "F", # pyflakes
74
- "I", # isort
75
- "S", # flake8-bandit
76
- "UP", # pyupgrade
77
- "W", # pycodestyle warnings
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.7.3'
22
- __version_tuple__ = version_tuple = (0, 7, 3)
21
+ __version__ = version = '0.8.1'
22
+ __version_tuple__ = version_tuple = (0, 8, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g0eee0b8b7'
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
- """Task with an optional retry callback for backoff scheduling.
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, (TaskResult, TaskError)):
178
+ if isinstance(o, TaskResult):
155
179
  return {
156
180
  field.name: getattr(o, field.name)
157
- for field in dataclasses.fields(type(o))
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
- data = {
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
- raise InvalidTask(
208
- "Task's retry function must be defined at a module level or be a deconstructible callable."
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
- ) -> TaskResult:
268
+ ) -> list[ThreadmillTaskResult]:
217
269
  """
218
- Return and lock the next task to be processed without removing it from the queue.
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
- timeout: The maximum time to wait for a task. If None, wait indefinitely.
223
- worker: The name of the worker thread acquiring the task.
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: If no task is available within the specified timeout.
227
- queue.Empty: If no task is available and timeout is None.
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
- """Re-queue a failed task result for a retry attempt after `run_after`.
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
- """Delete every task across all segments of a queue.
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