threadmill 0.1.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- threadmill-0.3.1/PKG-INFO +180 -0
- threadmill-0.3.1/README.md +137 -0
- {threadmill-0.1.0 → threadmill-0.3.1}/pyproject.toml +11 -2
- {threadmill-0.1.0 → threadmill-0.3.1}/threadmill/__init__.py +1 -1
- {threadmill-0.1.0 → threadmill-0.3.1}/threadmill/_version.py +3 -3
- threadmill-0.3.1/threadmill/backends/base.py +220 -0
- threadmill-0.3.1/threadmill/backends/lua/acknowledge.lua +34 -0
- threadmill-0.3.1/threadmill/backends/lua/acquire.lua +44 -0
- threadmill-0.3.1/threadmill/backends/lua/mover.lua +19 -0
- threadmill-0.3.1/threadmill/backends/lua/reaper.lua +40 -0
- threadmill-0.3.1/threadmill/backends/redis.py +469 -0
- threadmill-0.3.1/threadmill/exceptions.py +7 -0
- {threadmill-0.1.0 → threadmill-0.3.1}/threadmill/executor.py +48 -77
- threadmill-0.3.1/threadmill/inspector/__init__.py +5 -0
- threadmill-0.3.1/threadmill/inspector/app.py +463 -0
- threadmill-0.3.1/threadmill/inspector/inspector.scss +106 -0
- threadmill-0.3.1/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.1.0 → threadmill-0.3.1}/threadmill/management/commands/threadmill.py +57 -16
- threadmill-0.1.0/PKG-INFO +0 -175
- threadmill-0.1.0/README.md +0 -137
- threadmill-0.1.0/threadmill/backends.py +0 -34
- {threadmill-0.1.0 → threadmill-0.3.1}/LICENSE +0 -0
- {threadmill-0.1.0/threadmill/management → threadmill-0.3.1/threadmill/backends}/__init__.py +0 -0
- {threadmill-0.1.0/threadmill/management/commands → threadmill-0.3.1/threadmill/management}/__init__.py +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: threadmill
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: The most reliable backend for Django's task framework.
|
|
5
|
+
Keywords: Django,tasks,worker
|
|
6
|
+
Author-email: Johannes Maron <johannes@maron.family>
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
19
|
+
Classifier: Topic :: Software Development
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Framework :: Django
|
|
27
|
+
Classifier: Framework :: Django :: 6.0
|
|
28
|
+
Classifier: Framework :: Django :: 6.1
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: django>=6.0
|
|
31
|
+
Requires-Dist: textual>=8.2.7 ; extra == "inspector"
|
|
32
|
+
Requires-Dist: redis>=5.0 ; extra == "redis"
|
|
33
|
+
Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
|
|
34
|
+
Project-URL: Documentation, https://github.com/codingjoe/threadmill/
|
|
35
|
+
Project-URL: Funding, https://github.com/sponsors/codingjoe
|
|
36
|
+
Project-URL: Homepage, https://github.com/codingjoe/threadmill
|
|
37
|
+
Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
|
|
38
|
+
Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
|
|
39
|
+
Project-URL: Source, https://github.com/codingjoe/threadmill
|
|
40
|
+
Provides-Extra: inspector
|
|
41
|
+
Provides-Extra: redis
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<picture>
|
|
45
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-dark.svg">
|
|
46
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
|
|
47
|
+
<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">
|
|
48
|
+
</picture>
|
|
49
|
+
<br>
|
|
50
|
+
<a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
|
|
51
|
+
<a href="https://github.com/codingjoe/threadmill/issues/new/choose">Issues</a> |
|
|
52
|
+
<a href="https://github.com/codingjoe/threadmill/releases">Changelog</a> |
|
|
53
|
+
<a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
# Threadmill [](https://pypi.python.org/pypi/threadmill/) [](https://codecov.io/gh/codingjoe/threadmill) [](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
|
|
57
|
+
|
|
58
|
+
**Durable high-performance backend for Django's task framework.**
|
|
59
|
+
|
|
60
|
+
## Design Principles
|
|
61
|
+
|
|
62
|
+
- **Durability** – We recover from any failures, even poorly written tasks.
|
|
63
|
+
- **Consistency** – We never lose data, even if someone unplugs the power or network.
|
|
64
|
+
- **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
|
|
65
|
+
|
|
66
|
+
## Sponsors
|
|
67
|
+
|
|
68
|
+
[](https://github.com/sponsors/codingjoe)
|
|
69
|
+
|
|
70
|
+
## Setup
|
|
71
|
+
|
|
72
|
+
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
uv add threadmill[redis]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
79
|
+
and configure the task backend:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# settings.py
|
|
83
|
+
import os
|
|
84
|
+
|
|
85
|
+
INSTALLED_APPS = [
|
|
86
|
+
"threadmill",
|
|
87
|
+
# ...
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
TASKS = {
|
|
91
|
+
"default": {
|
|
92
|
+
"BACKEND": "threadmill.backends.redis.RedisTaskBackend",
|
|
93
|
+
"REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
94
|
+
},
|
|
95
|
+
# ...
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Optionally, install the inspector dependency if you want the TUI:
|
|
100
|
+
|
|
101
|
+
```console
|
|
102
|
+
uv add threadmill[inspector]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then launch the worker pool:
|
|
106
|
+
|
|
107
|
+
```console
|
|
108
|
+
uv run manage.py threadmill worker
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Usage
|
|
112
|
+
|
|
113
|
+
### Workers
|
|
114
|
+
|
|
115
|
+
The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
116
|
+
|
|
117
|
+
#### Utilization
|
|
118
|
+
|
|
119
|
+
Depending on your workload, you can tweak the number of processes and threads.
|
|
120
|
+
Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
|
|
121
|
+
|
|
122
|
+
```console
|
|
123
|
+
uv run manage.py threadmill worker --processes 4 --threads 2
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Health
|
|
127
|
+
|
|
128
|
+
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
129
|
+
|
|
130
|
+
```console
|
|
131
|
+
uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
Should a worker crash or be killed, the pool will automatically restart it.
|
|
137
|
+
|
|
138
|
+
#### Shutdown
|
|
139
|
+
|
|
140
|
+
A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
|
|
141
|
+
All workers will finish the tasks they acquired and acknowledge them.
|
|
142
|
+
|
|
143
|
+
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
144
|
+
which might be useful for draining a one-off queue.
|
|
145
|
+
|
|
146
|
+
### Inspector
|
|
147
|
+
|
|
148
|
+

|
|
149
|
+
|
|
150
|
+
The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
|
|
151
|
+
Install it with the `inspector` extra and launch it from a separate terminal:
|
|
152
|
+
|
|
153
|
+
```console
|
|
154
|
+
uv add threadmill[inspector]
|
|
155
|
+
uv run manage.py threadmill inspector
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Redis Backend Options
|
|
159
|
+
|
|
160
|
+
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
161
|
+
`TASKS` configuration:
|
|
162
|
+
|
|
163
|
+
| Option | Default | Description |
|
|
164
|
+
| ----------------- | ---------------------- | ------------------------------------------------------------ |
|
|
165
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
166
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
167
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
168
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
169
|
+
|
|
170
|
+
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
171
|
+
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
172
|
+
worst-case task runtime.
|
|
173
|
+
|
|
174
|
+
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
175
|
+
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
176
|
+
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
177
|
+
on cross-slot operations.
|
|
178
|
+
|
|
179
|
+
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
180
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
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 [](https://pypi.python.org/pypi/threadmill/) [](https://codecov.io/gh/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** – We recover from any failures, even poorly written tasks.
|
|
21
|
+
- **Consistency** – We never lose data, even if someone unplugs the power or network.
|
|
22
|
+
- **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
|
|
23
|
+
|
|
24
|
+
## Sponsors
|
|
25
|
+
|
|
26
|
+
[](https://github.com/sponsors/codingjoe)
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
You need to have [Django's Task framework][django-tasks] set up properly.
|
|
31
|
+
|
|
32
|
+
```console
|
|
33
|
+
uv add threadmill[redis]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
|
|
37
|
+
and configure the task backend:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# settings.py
|
|
41
|
+
import os
|
|
42
|
+
|
|
43
|
+
INSTALLED_APPS = [
|
|
44
|
+
"threadmill",
|
|
45
|
+
# ...
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
TASKS = {
|
|
49
|
+
"default": {
|
|
50
|
+
"BACKEND": "threadmill.backends.redis.RedisTaskBackend",
|
|
51
|
+
"REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
52
|
+
},
|
|
53
|
+
# ...
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Optionally, install the inspector dependency if you want the TUI:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
uv add threadmill[inspector]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then launch the worker pool:
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
uv run manage.py threadmill worker
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### Workers
|
|
72
|
+
|
|
73
|
+
The workers are inspired by Gunicorn, and the CLI is very similar.
|
|
74
|
+
|
|
75
|
+
#### Utilization
|
|
76
|
+
|
|
77
|
+
Depending on your workload, you can tweak the number of processes and threads.
|
|
78
|
+
Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
uv run manage.py threadmill worker --processes 4 --threads 2
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Health
|
|
85
|
+
|
|
86
|
+
If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
Should a worker crash or be killed, the pool will automatically restart it.
|
|
95
|
+
|
|
96
|
+
#### Shutdown
|
|
97
|
+
|
|
98
|
+
A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
|
|
99
|
+
All workers will finish the tasks they acquired and acknowledge them.
|
|
100
|
+
|
|
101
|
+
You can use `--exit-empty` to exit immediately after all tasks have been processed,
|
|
102
|
+
which might be useful for draining a one-off queue.
|
|
103
|
+
|
|
104
|
+
### Inspector
|
|
105
|
+
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+
The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
|
|
109
|
+
Install it with the `inspector` extra and launch it from a separate terminal:
|
|
110
|
+
|
|
111
|
+
```console
|
|
112
|
+
uv add threadmill[inspector]
|
|
113
|
+
uv run manage.py threadmill inspector
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Redis Backend Options
|
|
117
|
+
|
|
118
|
+
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
119
|
+
`TASKS` configuration:
|
|
120
|
+
|
|
121
|
+
| Option | Default | Description |
|
|
122
|
+
| ----------------- | ---------------------- | ------------------------------------------------------------ |
|
|
123
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
124
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
125
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
126
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
127
|
+
|
|
128
|
+
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
129
|
+
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
130
|
+
worst-case task runtime.
|
|
131
|
+
|
|
132
|
+
All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
|
|
133
|
+
every multi-key operation — including the cross-queue acquire — runs on a single
|
|
134
|
+
shard. Scale horizontally by running additional backend aliases, not by relying
|
|
135
|
+
on cross-slot operations.
|
|
136
|
+
|
|
137
|
+
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
|
|
@@ -30,10 +30,17 @@ classifiers = [
|
|
|
30
30
|
"Programming Language :: Python :: 3.13",
|
|
31
31
|
"Programming Language :: Python :: 3.14",
|
|
32
32
|
"Framework :: Django",
|
|
33
|
+
"Framework :: Django :: 6.0",
|
|
33
34
|
"Framework :: Django :: 6.1",
|
|
34
35
|
]
|
|
35
36
|
requires-python = ">=3.12"
|
|
36
|
-
dependencies = ["django>=6.
|
|
37
|
+
dependencies = ["django>=6.0"]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
redis = ["redis>=5.0"]
|
|
41
|
+
inspector = [
|
|
42
|
+
"textual>=8.2.7",
|
|
43
|
+
]
|
|
37
44
|
|
|
38
45
|
[project.urls]
|
|
39
46
|
# https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
|
|
@@ -56,9 +63,9 @@ minversion = "6.0"
|
|
|
56
63
|
addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
|
|
57
64
|
testpaths = ["tests"]
|
|
58
65
|
DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
|
|
66
|
+
asyncio_mode = "auto"
|
|
59
67
|
markers = [
|
|
60
68
|
"benchmark: mark benchmark tests.",
|
|
61
|
-
"integration: mark integration tests.",
|
|
62
69
|
]
|
|
63
70
|
|
|
64
71
|
[tool.coverage.run]
|
|
@@ -91,6 +98,7 @@ combine-as-imports = true
|
|
|
91
98
|
split-on-trailing-comma = true
|
|
92
99
|
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
93
100
|
force-wrap-aliases = true
|
|
101
|
+
known-first-party = ["threadmill", "tests"]
|
|
94
102
|
|
|
95
103
|
[tool.ruff.lint.pydocstyle]
|
|
96
104
|
convention = "pep257"
|
|
@@ -105,4 +113,5 @@ test = [
|
|
|
105
113
|
"pytest-asyncio",
|
|
106
114
|
"pytest-cov",
|
|
107
115
|
"pytest-django",
|
|
116
|
+
"redis>=5.0",
|
|
108
117
|
]
|
|
@@ -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.1
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g32b680b8b'
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
from abc import ABC
|
|
9
|
+
|
|
10
|
+
import django
|
|
11
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
12
|
+
from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
|
|
13
|
+
from django.tasks.backends.base import BaseTaskBackend
|
|
14
|
+
from django.tasks.base import TaskError
|
|
15
|
+
from django.utils.module_loading import import_string
|
|
16
|
+
|
|
17
|
+
if django.VERSION == (6, 0):
|
|
18
|
+
# https://github.com/django/django/commit/8c8b833d32c02d3ae6f43b04bb1e45968796b402
|
|
19
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
20
|
+
class Task(Task):
|
|
21
|
+
@classmethod
|
|
22
|
+
def _reconstruct(cls, kwargs):
|
|
23
|
+
func_path = kwargs["func"]
|
|
24
|
+
try:
|
|
25
|
+
func = import_string(func_path)
|
|
26
|
+
kwargs["func"] = func.func
|
|
27
|
+
except (ImportError, AttributeError) as e:
|
|
28
|
+
msg = f"Expected {func_path!r} to point to a Task instance."
|
|
29
|
+
raise ValueError(msg) from e
|
|
30
|
+
return cls(**kwargs)
|
|
31
|
+
|
|
32
|
+
def __reduce__(self):
|
|
33
|
+
kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
|
|
34
|
+
kwargs["func"] = self.module_path
|
|
35
|
+
|
|
36
|
+
return (self.__class__._reconstruct, (kwargs,))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
40
|
+
class QueueCounts:
|
|
41
|
+
"""Point-in-time cardinality of each queue segment."""
|
|
42
|
+
|
|
43
|
+
ready: int
|
|
44
|
+
running: int
|
|
45
|
+
deferred: int
|
|
46
|
+
successful: int
|
|
47
|
+
failed: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
51
|
+
class QueueRates:
|
|
52
|
+
"""Rolling ingress/egress throughput over a time window (time-series data)."""
|
|
53
|
+
|
|
54
|
+
interval: datetime.timedelta
|
|
55
|
+
ingress: int
|
|
56
|
+
egress: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
60
|
+
class QueueStats:
|
|
61
|
+
"""Telemetry for a single queue: point-in-time counts plus rolling rates."""
|
|
62
|
+
|
|
63
|
+
counts: QueueCounts
|
|
64
|
+
rates: QueueRates
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
68
|
+
class BackendTelemetry:
|
|
69
|
+
"""Snapshot of counts and rates across a backend's queues."""
|
|
70
|
+
|
|
71
|
+
queues: dict[str, QueueStats]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Broker(threading.Thread):
|
|
75
|
+
"""Backend maintenance thread launched by the task executor."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
backend: ThreadmillTaskBackend | None = None,
|
|
80
|
+
*,
|
|
81
|
+
interval: datetime.timedelta = datetime.timedelta(seconds=1),
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(daemon=True)
|
|
84
|
+
self.backend = backend
|
|
85
|
+
self.interval = interval
|
|
86
|
+
self.shutdown_requested = threading.Event()
|
|
87
|
+
|
|
88
|
+
def main(self) -> None:
|
|
89
|
+
"""Perform one maintenance pass."""
|
|
90
|
+
|
|
91
|
+
def run(self) -> None:
|
|
92
|
+
while not self.shutdown_requested.wait(self.interval.total_seconds()):
|
|
93
|
+
self.main()
|
|
94
|
+
|
|
95
|
+
def shutdown(self) -> None:
|
|
96
|
+
"""Request graceful shutdown."""
|
|
97
|
+
self.shutdown_requested.set()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_datetime(value: object) -> object:
|
|
101
|
+
"""Parse an ISO datetime string, returning the value unchanged if not parseable."""
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
try:
|
|
104
|
+
return datetime.datetime.fromisoformat(value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
return value
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TaskResultEncoder(DjangoJSONEncoder):
|
|
111
|
+
"""JSON encoder for TaskResult and TaskError objects."""
|
|
112
|
+
|
|
113
|
+
def default(self, o):
|
|
114
|
+
if isinstance(o, (TaskResult, TaskError)):
|
|
115
|
+
return {
|
|
116
|
+
field.name: getattr(o, field.name)
|
|
117
|
+
for field in dataclasses.fields(type(o))
|
|
118
|
+
}
|
|
119
|
+
if isinstance(o, Task):
|
|
120
|
+
return {
|
|
121
|
+
field.name: getattr(o, field.name)
|
|
122
|
+
for field in dataclasses.fields(Task)
|
|
123
|
+
if field.name != "func" # Exclude the function object itself
|
|
124
|
+
} | {"func": o.module_path}
|
|
125
|
+
return super().default(o)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
129
|
+
"""Interface for task queues to be processed by the executor."""
|
|
130
|
+
|
|
131
|
+
task_class = Task # can be removed in the future when Django 6.0 support is dropped
|
|
132
|
+
supports_async_task = True
|
|
133
|
+
supports_get_result = True
|
|
134
|
+
broker_class: type[Broker] | None = None
|
|
135
|
+
|
|
136
|
+
result_ttl: datetime.timedelta | None = None
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def serialize_task_result(task_result: TaskResult) -> str:
|
|
140
|
+
return json.dumps(task_result, cls=TaskResultEncoder)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def deserialize_task_result(cls, data: str) -> TaskResult:
|
|
144
|
+
def _object_hook(d: dict) -> dict | TaskResult:
|
|
145
|
+
if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
|
|
146
|
+
task_data = d["task"]
|
|
147
|
+
func = import_string(task_data["func"])
|
|
148
|
+
if isinstance(func, cls.task_class):
|
|
149
|
+
func = func.func
|
|
150
|
+
d["task"] = cls.task_class(
|
|
151
|
+
func=func,
|
|
152
|
+
**{
|
|
153
|
+
field.name: _parse_datetime(task_data[field.name])
|
|
154
|
+
for field in dataclasses.fields(cls.task_class)
|
|
155
|
+
if field.name not in {"func", "takes_context"}
|
|
156
|
+
and field.name in task_data
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
d["status"] = TaskResultStatus(d["status"])
|
|
160
|
+
d["errors"] = [TaskError(**error) for error in d["errors"]]
|
|
161
|
+
return_value = d.pop("_return_value", None)
|
|
162
|
+
for key, value in d.items():
|
|
163
|
+
d[key] = _parse_datetime(value)
|
|
164
|
+
result = TaskResult(**d)
|
|
165
|
+
object.__setattr__(result, "_return_value", return_value)
|
|
166
|
+
return result
|
|
167
|
+
return d
|
|
168
|
+
|
|
169
|
+
return json.loads(data, object_hook=_object_hook)
|
|
170
|
+
|
|
171
|
+
def acquire(
|
|
172
|
+
self,
|
|
173
|
+
*queue_names: str,
|
|
174
|
+
timeout: datetime.timedelta | None = None,
|
|
175
|
+
worker: str = "",
|
|
176
|
+
) -> TaskResult:
|
|
177
|
+
"""
|
|
178
|
+
Return and lock the next task to be processed without removing it from the queue.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
queue_names: The names of the queues to acquire tasks from.
|
|
182
|
+
timeout: The maximum time to wait for a task. If None, wait indefinitely.
|
|
183
|
+
worker: The name of the worker thread acquiring the task.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
TimeoutError: If no task is available within the specified timeout.
|
|
187
|
+
queue.Empty: If no task is available and timeout is None.
|
|
188
|
+
"""
|
|
189
|
+
raise NotImplementedError
|
|
190
|
+
|
|
191
|
+
def acknowledge(self, task_result: TaskResult) -> None:
|
|
192
|
+
"""Remove the task from the queue and publish the result."""
|
|
193
|
+
raise NotImplementedError
|
|
194
|
+
|
|
195
|
+
def peek(
|
|
196
|
+
self,
|
|
197
|
+
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
198
|
+
*,
|
|
199
|
+
status: TaskResultStatus,
|
|
200
|
+
count: int = 1,
|
|
201
|
+
) -> collections.abc.Generator[TaskResult, None, None]:
|
|
202
|
+
"""
|
|
203
|
+
Yield up to ``count`` tasks from a queue in the given status segment.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
queue_name: The name of the queue to peek into.
|
|
207
|
+
status: The status of the tasks to yield.
|
|
208
|
+
count: The maximum number of tasks to yield. If 0, yield all available tasks.
|
|
209
|
+
"""
|
|
210
|
+
raise NotImplementedError
|
|
211
|
+
|
|
212
|
+
def telemetry(
|
|
213
|
+
self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
|
|
214
|
+
) -> BackendTelemetry:
|
|
215
|
+
"""Return a snapshot of stats for all configured queues.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
interval: The time window for rolling rates.
|
|
219
|
+
"""
|
|
220
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Finalize a completed task: remove it from the running set, persist the
|
|
2
|
+
-- result with a TTL, delete the task data hash, and add the result to the
|
|
3
|
+
-- per-status results history set. Evicts results whose finish score falls
|
|
4
|
+
-- outside the retention window (result_ttl). The per-status history sets are
|
|
5
|
+
-- the time series the inspector counts over for telemetry, so no separate
|
|
6
|
+
-- egress window or status counters are needed here.
|
|
7
|
+
--
|
|
8
|
+
-- KEYS[1] -- running set (ZSET)
|
|
9
|
+
-- KEYS[2] -- result key (STRING, stores serialized TaskResult)
|
|
10
|
+
-- KEYS[3] -- task data key (HASH, deleted after acknowledge)
|
|
11
|
+
-- KEYS[4] -- successful results history (ZSET, scored by finish time)
|
|
12
|
+
-- KEYS[5] -- failed results history (ZSET, scored by finish time)
|
|
13
|
+
-- ARGV[1] -- task ID
|
|
14
|
+
-- ARGV[2] -- serialized TaskResult JSON
|
|
15
|
+
-- ARGV[3] -- result TTL in seconds
|
|
16
|
+
-- ARGV[4] -- finish timestamp in milliseconds (score for the history set)
|
|
17
|
+
-- ARGV[5] -- status (SUCCESSFUL or FAILED)
|
|
18
|
+
-- Returns: 1 on success, 0 if task was not in the running set
|
|
19
|
+
|
|
20
|
+
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
21
|
+
if removed == 0 then
|
|
22
|
+
return 0 -- Task already reaped, skip
|
|
23
|
+
end
|
|
24
|
+
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3])
|
|
25
|
+
redis.call('DEL', KEYS[3])
|
|
26
|
+
local finish = tonumber(ARGV[4])
|
|
27
|
+
local cutoff = finish - tonumber(ARGV[3]) * 1000
|
|
28
|
+
local results_key = KEYS[4]
|
|
29
|
+
if ARGV[5] ~= 'SUCCESSFUL' then
|
|
30
|
+
results_key = KEYS[5]
|
|
31
|
+
end
|
|
32
|
+
redis.call('ZADD', results_key, finish, ARGV[1])
|
|
33
|
+
redis.call('ZREMRANGEBYSCORE', results_key, 0, cutoff)
|
|
34
|
+
return 1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Atomically pop the lowest-scored task from any of the given priority queues,
|
|
2
|
+
-- update its JSON data with worker info, and move it directly to the running
|
|
3
|
+
-- set. Iterates queues in key order and returns the first available task.
|
|
4
|
+
--
|
|
5
|
+
-- KEYS[1..N] -- interleaved running keys and queue keys, one pair per queue:
|
|
6
|
+
-- KEYS[1] = running set, KEYS[2] = queue set, KEYS[3] = running,
|
|
7
|
+
-- KEYS[4] = queue, etc.
|
|
8
|
+
-- ARGV[1] -- current time in milliseconds
|
|
9
|
+
-- ARGV[2] -- current time as ISO-8601 string
|
|
10
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
|
|
11
|
+
-- ARGV[4] -- number of queue pairs (N/2)
|
|
12
|
+
-- ARGV[5] -- worker name
|
|
13
|
+
-- ARGV[6] -- lease TTL in milliseconds
|
|
14
|
+
-- Returns: updated serialized data on success, nil if all queues are empty.
|
|
15
|
+
|
|
16
|
+
local num_queues = tonumber(ARGV[4])
|
|
17
|
+
local lease_ttl_ms = tonumber(ARGV[6])
|
|
18
|
+
for i = 1, num_queues do
|
|
19
|
+
local result = redis.call('ZPOPMIN', KEYS[i * 2])
|
|
20
|
+
if #result > 0 then
|
|
21
|
+
local task_id = result[1]
|
|
22
|
+
local data = redis.call('HGET', ARGV[3] .. task_id, 'data')
|
|
23
|
+
if data then
|
|
24
|
+
local ok, parsed = pcall(cjson.decode, data)
|
|
25
|
+
if ok then
|
|
26
|
+
parsed.status = 'RUNNING'
|
|
27
|
+
parsed.last_attempted_at = ARGV[2]
|
|
28
|
+
if not parsed.started_at then
|
|
29
|
+
parsed.started_at = ARGV[2]
|
|
30
|
+
end
|
|
31
|
+
if not parsed.worker_ids then
|
|
32
|
+
parsed.worker_ids = {}
|
|
33
|
+
end
|
|
34
|
+
table.insert(parsed.worker_ids, ARGV[5])
|
|
35
|
+
local updated_data = cjson.encode(parsed)
|
|
36
|
+
local deadline = tonumber(ARGV[1]) + lease_ttl_ms
|
|
37
|
+
redis.call('ZADD', KEYS[i * 2 - 1], deadline, task_id)
|
|
38
|
+
redis.call('HSET', ARGV[3] .. task_id, 'data', updated_data)
|
|
39
|
+
return updated_data
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
return nil
|