threadmill 0.2.0__tar.gz → 0.4.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 (25) hide show
  1. {threadmill-0.2.0 → threadmill-0.4.0}/PKG-INFO +109 -29
  2. threadmill-0.4.0/README.md +206 -0
  3. {threadmill-0.2.0 → threadmill-0.4.0}/pyproject.toml +6 -5
  4. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/_version.py +3 -3
  5. threadmill-0.4.0/threadmill/backends/base.py +248 -0
  6. threadmill-0.4.0/threadmill/backends/lua/acknowledge.lua +34 -0
  7. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/backends/lua/reaper.lua +6 -2
  8. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/backends/redis.py +197 -25
  9. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/exceptions.py +0 -2
  10. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/executor.py +17 -4
  11. threadmill-0.4.0/threadmill/inspector/__init__.py +5 -0
  12. threadmill-0.4.0/threadmill/inspector/app.py +461 -0
  13. threadmill-0.4.0/threadmill/inspector/inspector.scss +106 -0
  14. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/management/commands/threadmill.py +57 -7
  15. threadmill-0.4.0/threadmill/retry.py +71 -0
  16. threadmill-0.2.0/README.md +0 -126
  17. threadmill-0.2.0/threadmill/backends/base.py +0 -145
  18. threadmill-0.2.0/threadmill/backends/lua/acknowledge.lua +0 -24
  19. {threadmill-0.2.0 → threadmill-0.4.0}/LICENSE +0 -0
  20. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/__init__.py +0 -0
  21. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/backends/__init__.py +0 -0
  22. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/backends/lua/acquire.lua +0 -0
  23. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/backends/lua/mover.lua +0 -0
  24. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/management/__init__.py +0 -0
  25. {threadmill-0.2.0 → threadmill-0.4.0}/threadmill/management/commands/__init__.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.2.0
3
+ Version: 0.4.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>
7
- Requires-Python: >=3.12
7
+ Requires-Python: >=3.14
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Programming Language :: Python
@@ -20,13 +20,12 @@ Classifier: Topic :: Software Development
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Programming Language :: Python :: 3
22
22
  Classifier: Programming Language :: Python :: 3 :: Only
23
- Classifier: Programming Language :: Python :: 3.12
24
- Classifier: Programming Language :: Python :: 3.13
25
23
  Classifier: Programming Language :: Python :: 3.14
26
24
  Classifier: Framework :: Django
27
25
  Classifier: Framework :: Django :: 6.1
28
26
  License-File: LICENSE
29
- Requires-Dist: django>=6.1a1
27
+ Requires-Dist: django>=6.0
28
+ Requires-Dist: textual>=8.2.7 ; extra == "inspector"
30
29
  Requires-Dist: redis>=5.0 ; extra == "redis"
31
30
  Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
32
31
  Project-URL: Documentation, https://github.com/codingjoe/threadmill/
@@ -35,15 +34,14 @@ Project-URL: Homepage, https://github.com/codingjoe/threadmill
35
34
  Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
36
35
  Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
37
36
  Project-URL: Source, https://github.com/codingjoe/threadmill
37
+ Provides-Extra: inspector
38
38
  Provides-Extra: redis
39
39
 
40
- # Threadmill
41
-
42
40
  <p align="center">
43
41
  <picture>
44
- <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
45
- <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
46
- <img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
42
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-dark.svg">
43
+ <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
44
+ <img alt="Threadmill: Durable high-performance backend for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
47
45
  </picture>
48
46
  <br>
49
47
  <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
@@ -52,24 +50,15 @@ Provides-Extra: redis
52
50
  <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
53
51
  </p>
54
52
 
55
- **A queue agnostic worker for Django's task framework.**
56
-
57
- ## Design Principles
58
-
59
- - **Durability** – We recover from any failures, even poorly written tasks.
60
- - **Consistency** – We never lose data, even if someone unplugs the power or network.
61
- - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
62
-
63
- > [!WARNING]
64
- > Threadmill requires a development version of Django and is in a preview stage.
53
+ # 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)
65
54
 
66
- [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/)
67
- [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill)
68
- [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
55
+ **Durable high-performance backend for Django's task framework.**
69
56
 
70
- ## Sponsors
57
+ ## Design Principles
71
58
 
72
- [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
59
+ - **Durability** – Recover from any failures, even poorly written tasks.
60
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
61
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
73
62
 
74
63
  ## Setup
75
64
 
@@ -100,10 +89,16 @@ TASKS = {
100
89
  }
101
90
  ```
102
91
 
103
- Finally, you launch the worker pool:
92
+ Optionally, install the inspector dependency if you want the TUI:
93
+
94
+ ```console
95
+ uv add threadmill[inspector]
96
+ ```
97
+
98
+ Then launch the worker pool:
104
99
 
105
100
  ```console
106
- uv run manage.py threadmill
101
+ uv run manage.py threadmill worker
107
102
  ```
108
103
 
109
104
  ## Usage
@@ -118,7 +113,7 @@ Depending on your workload, you can tweak the number of processes and threads.
118
113
  Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
119
114
 
120
115
  ```console
121
- uv run manage.py threadmill --processes 4 --threads 2
116
+ uv run manage.py threadmill worker --processes 4 --threads 2
122
117
  ```
123
118
 
124
119
  #### Health
@@ -126,7 +121,7 @@ uv run manage.py threadmill --processes 4 --threads 2
126
121
  If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
127
122
 
128
123
  ```console
129
- uv run manage.py threadmill --max-tasks 1000 --max-tasks-jitter 100
124
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
130
125
  ```
131
126
 
132
127
  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.
@@ -141,6 +136,18 @@ All workers will finish the tasks they acquired and acknowledge them.
141
136
  You can use `--exit-empty` to exit immediately after all tasks have been processed,
142
137
  which might be useful for draining a one-off queue.
143
138
 
139
+ ### Inspector
140
+
141
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
142
+
143
+ The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
144
+ Install it with the `inspector` extra and launch it from a separate terminal:
145
+
146
+ ```console
147
+ uv add threadmill[inspector]
148
+ uv run manage.py threadmill inspector
149
+ ```
150
+
144
151
  ### Redis Backend Options
145
152
 
146
153
  The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
@@ -162,5 +169,78 @@ every multi-key operation — including the cross-queue acquire — runs on a si
162
169
  shard. Scale horizontally by running additional backend aliases, not by relying
163
170
  on cross-slot operations.
164
171
 
172
+ ### Retrying failed tasks
173
+
174
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
175
+ The callback receives a `TaskContext` — use `context.attempt` for the current
176
+ attempt count and `context.task_result.errors[-1]` for the latest error.
177
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
178
+
179
+ The worker re-queues the failed task, preserving its ID and error history;
180
+ the broker promotes it back to the ready queue once the delay elapses.
181
+
182
+ #### Built-in `ExponentialBackoff`
183
+
184
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
185
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
186
+ after `max_retries` attempts, and only retries exceptions listed in
187
+ `expected_exceptions`.
188
+
189
+ ```python
190
+ import datetime
191
+
192
+ from django.tasks import task
193
+ from requests import HTTPError
194
+
195
+ from threadmill.retry import ExponentialBackoff
196
+
197
+
198
+ @task(
199
+ retry=ExponentialBackoff(
200
+ base_delay=datetime.timedelta(seconds=1),
201
+ max_delay=datetime.timedelta(minutes=5),
202
+ factor=2.0,
203
+ max_retries=5,
204
+ expected_exceptions=(HTTPError,),
205
+ )
206
+ )
207
+ def fetch_github_api(url: str): ...
208
+ ```
209
+
210
+ #### Custom retry callbacks
211
+
212
+ For cases that need logic beyond what `ExponentialBackoff` supports,
213
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
214
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
215
+
216
+ ```python
217
+ import datetime
218
+
219
+ from django.tasks import task
220
+ from django.tasks.base import TaskContext
221
+ from requests import HTTPError
222
+
223
+
224
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
225
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
226
+ if context.attempt >= 5:
227
+ return None
228
+ error = context.task_result.errors[-1]
229
+ if not issubclass(error.exception_class, HTTPError):
230
+ return None
231
+ return min(
232
+ datetime.timedelta(seconds=2**context.attempt),
233
+ datetime.timedelta(seconds=60),
234
+ )
235
+
236
+
237
+ @task(retry=retry_on_rate_limit)
238
+ def fetch_github_api(url: str): ...
239
+ ```
240
+
241
+ ## Sponsors
242
+
243
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
244
+
165
245
  [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
166
246
 
@@ -0,0 +1,206 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
5
+ <img alt="Threadmill: Durable high-performance backend for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
6
+ </picture>
7
+ <br>
8
+ <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
9
+ <a href="https://github.com/codingjoe/threadmill/issues/new/choose">Issues</a> |
10
+ <a href="https://github.com/codingjoe/threadmill/releases">Changelog</a> |
11
+ <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
12
+ </p>
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)
15
+
16
+ **Durable high-performance backend for Django's task framework.**
17
+
18
+ ## Design Principles
19
+
20
+ - **Durability** – Recover from any failures, even poorly written tasks.
21
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
22
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
23
+
24
+ ## Setup
25
+
26
+ You need to have [Django's Task framework][django-tasks] set up properly.
27
+
28
+ ```console
29
+ uv add threadmill[redis]
30
+ ```
31
+
32
+ Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
33
+ and configure the task backend:
34
+
35
+ ```python
36
+ # settings.py
37
+ import os
38
+
39
+ INSTALLED_APPS = [
40
+ "threadmill",
41
+ # ...
42
+ ]
43
+
44
+ TASKS = {
45
+ "default": {
46
+ "BACKEND": "threadmill.backends.redis.RedisTaskBackend",
47
+ "REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
48
+ },
49
+ # ...
50
+ }
51
+ ```
52
+
53
+ Optionally, install the inspector dependency if you want the TUI:
54
+
55
+ ```console
56
+ uv add threadmill[inspector]
57
+ ```
58
+
59
+ Then launch the worker pool:
60
+
61
+ ```console
62
+ uv run manage.py threadmill worker
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Workers
68
+
69
+ The workers are inspired by Gunicorn, and the CLI is very similar.
70
+
71
+ #### Utilization
72
+
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.
75
+
76
+ ```console
77
+ uv run manage.py threadmill worker --processes 4 --threads 2
78
+ ```
79
+
80
+ #### Health
81
+
82
+ If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
83
+
84
+ ```console
85
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
86
+ ```
87
+
88
+ 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
+
90
+ Should a worker crash or be killed, the pool will automatically restart it.
91
+
92
+ #### Shutdown
93
+
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.
96
+
97
+ You can use `--exit-empty` to exit immediately after all tasks have been processed,
98
+ which might be useful for draining a one-off queue.
99
+
100
+ ### Inspector
101
+
102
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
103
+
104
+ The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
105
+ Install it with the `inspector` extra and launch it from a separate terminal:
106
+
107
+ ```console
108
+ uv add threadmill[inspector]
109
+ uv run manage.py threadmill inspector
110
+ ```
111
+
112
+ ### Redis Backend Options
113
+
114
+ The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
115
+ `TASKS` configuration:
116
+
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
+
124
+ A task that is started but never acknowledged (lease expired) is marked FAILED
125
+ with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
126
+ worst-case task runtime.
127
+
128
+ All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
129
+ every multi-key operation — including the cross-queue acquire — runs on a single
130
+ shard. Scale horizontally by running additional backend aliases, not by relying
131
+ on cross-slot operations.
132
+
133
+ ### Retrying failed tasks
134
+
135
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
136
+ The callback receives a `TaskContext` — use `context.attempt` for the current
137
+ attempt count and `context.task_result.errors[-1]` for the latest error.
138
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
139
+
140
+ The worker re-queues the failed task, preserving its ID and error history;
141
+ the broker promotes it back to the ready queue once the delay elapses.
142
+
143
+ #### Built-in `ExponentialBackoff`
144
+
145
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
146
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
147
+ after `max_retries` attempts, and only retries exceptions listed in
148
+ `expected_exceptions`.
149
+
150
+ ```python
151
+ import datetime
152
+
153
+ from django.tasks import task
154
+ from requests import HTTPError
155
+
156
+ from threadmill.retry import ExponentialBackoff
157
+
158
+
159
+ @task(
160
+ retry=ExponentialBackoff(
161
+ base_delay=datetime.timedelta(seconds=1),
162
+ max_delay=datetime.timedelta(minutes=5),
163
+ factor=2.0,
164
+ max_retries=5,
165
+ expected_exceptions=(HTTPError,),
166
+ )
167
+ )
168
+ def fetch_github_api(url: str): ...
169
+ ```
170
+
171
+ #### Custom retry callbacks
172
+
173
+ For cases that need logic beyond what `ExponentialBackoff` supports,
174
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
175
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
176
+
177
+ ```python
178
+ import datetime
179
+
180
+ from django.tasks import task
181
+ from django.tasks.base import TaskContext
182
+ from requests import HTTPError
183
+
184
+
185
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
186
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
187
+ if context.attempt >= 5:
188
+ return None
189
+ error = context.task_result.errors[-1]
190
+ if not issubclass(error.exception_class, HTTPError):
191
+ return None
192
+ return min(
193
+ datetime.timedelta(seconds=2**context.attempt),
194
+ datetime.timedelta(seconds=60),
195
+ )
196
+
197
+
198
+ @task(retry=retry_on_rate_limit)
199
+ def fetch_github_api(url: str): ...
200
+ ```
201
+
202
+ ## Sponsors
203
+
204
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
205
+
206
+ [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
@@ -26,17 +26,18 @@ classifiers = [
26
26
  "Programming Language :: Python",
27
27
  "Programming Language :: Python :: 3",
28
28
  "Programming Language :: Python :: 3 :: Only",
29
- "Programming Language :: Python :: 3.12",
30
- "Programming Language :: Python :: 3.13",
31
29
  "Programming Language :: Python :: 3.14",
32
30
  "Framework :: Django",
33
31
  "Framework :: Django :: 6.1",
34
32
  ]
35
- requires-python = ">=3.12"
36
- dependencies = ["django>=6.1a1"]
33
+ requires-python = ">=3.14"
34
+ dependencies = ["django>=6.0"]
37
35
 
38
36
  [project.optional-dependencies]
39
37
  redis = ["redis>=5.0"]
38
+ inspector = [
39
+ "textual>=8.2.7",
40
+ ]
40
41
 
41
42
  [project.urls]
42
43
  # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
@@ -55,7 +56,7 @@ name = "threadmill"
55
56
  write_to = "threadmill/_version.py"
56
57
 
57
58
  [tool.pytest.ini_options]
58
- minversion = "6.0"
59
+ minversion = "6.1a1"
59
60
  addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
60
61
  testpaths = ["tests"]
61
62
  DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
@@ -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.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gb135558ff'
24
+ __commit_id__ = commit_id = 'gf9749f870'