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.
Files changed (28) hide show
  1. {threadmill-0.7.2 → threadmill-0.8.0}/PKG-INFO +59 -25
  2. {threadmill-0.7.2 → threadmill-0.8.0}/README.md +56 -24
  3. {threadmill-0.7.2 → threadmill-0.8.0}/pyproject.toml +9 -2
  4. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/_version.py +3 -3
  5. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/base.py +65 -8
  6. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/lua/acknowledge.lua +5 -0
  7. threadmill-0.8.0/threadmill/backends/lua/acquire.lua +57 -0
  8. threadmill-0.8.0/threadmill/backends/lua/reaper.lua +23 -0
  9. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/redis.py +218 -59
  10. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/executor.py +142 -49
  11. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/commands/threadmill.py +15 -2
  12. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/retry.py +5 -1
  13. threadmill-0.7.2/threadmill/backends/lua/acquire.lua +0 -49
  14. threadmill-0.7.2/threadmill/backends/lua/reaper.lua +0 -40
  15. {threadmill-0.7.2 → threadmill-0.8.0}/LICENSE +0 -0
  16. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/__init__.py +0 -0
  17. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/apps.py +0 -0
  18. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/__init__.py +0 -0
  19. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/backends/lua/mover.lua +0 -0
  20. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/exceptions.py +0 -0
  21. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/__init__.py +0 -0
  22. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/app.py +0 -0
  23. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/inspector.scss +0 -0
  24. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/screens.py +0 -0
  25. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/inspector/telemetry.py +0 -0
  26. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/__init__.py +0 -0
  27. {threadmill-0.7.2 → threadmill-0.8.0}/threadmill/management/commands/__init__.py +0 -0
  28. {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.7.2
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 [![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,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 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
+
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
- 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.
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 [![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,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 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
+
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
- 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.
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.7.2'
22
- __version_tuple__ = version_tuple = (0, 7, 2)
21
+ __version__ = version = '0.8.0'
22
+ __version_tuple__ = version_tuple = (0, 8, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g560d3b3b5'
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, (TaskResult, TaskError)):
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(type(o))
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
- ) -> TaskResult:
264
+ ) -> list[ThreadmillTaskResult]:
217
265
  """
218
- Return and lock the next task to be processed without removing it from the queue.
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
- timeout: The maximum time to wait for a task. If None, wait indefinitely.
223
- worker: The name of the worker thread acquiring the task.
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: If no task is available within the specified timeout.
227
- queue.Empty: If no task is available and timeout is None.
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