threadmill 0.2.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.
Files changed (22) hide show
  1. {threadmill-0.2.0 → threadmill-0.3.1}/PKG-INFO +33 -19
  2. {threadmill-0.2.0 → threadmill-0.3.1}/README.md +28 -17
  3. {threadmill-0.2.0 → threadmill-0.3.1}/pyproject.toml +5 -1
  4. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/_version.py +3 -3
  5. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/base.py +77 -2
  6. threadmill-0.3.1/threadmill/backends/lua/acknowledge.lua +34 -0
  7. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/lua/reaper.lua +6 -2
  8. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/redis.py +168 -23
  9. threadmill-0.3.1/threadmill/inspector/__init__.py +5 -0
  10. threadmill-0.3.1/threadmill/inspector/app.py +463 -0
  11. threadmill-0.3.1/threadmill/inspector/inspector.scss +106 -0
  12. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/management/commands/threadmill.py +57 -7
  13. threadmill-0.2.0/threadmill/backends/lua/acknowledge.lua +0 -24
  14. {threadmill-0.2.0 → threadmill-0.3.1}/LICENSE +0 -0
  15. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/__init__.py +0 -0
  16. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/__init__.py +0 -0
  17. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/lua/acquire.lua +0 -0
  18. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/backends/lua/mover.lua +0 -0
  19. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/exceptions.py +0 -0
  20. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/executor.py +0 -0
  21. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/management/__init__.py +0 -0
  22. {threadmill-0.2.0 → threadmill-0.3.1}/threadmill/management/commands/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.2.0
3
+ Version: 0.3.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>
@@ -24,9 +24,11 @@ Classifier: Programming Language :: Python :: 3.12
24
24
  Classifier: Programming Language :: Python :: 3.13
25
25
  Classifier: Programming Language :: Python :: 3.14
26
26
  Classifier: Framework :: Django
27
+ Classifier: Framework :: Django :: 6.0
27
28
  Classifier: Framework :: Django :: 6.1
28
29
  License-File: LICENSE
29
- Requires-Dist: django>=6.1a1
30
+ Requires-Dist: django>=6.0
31
+ Requires-Dist: textual>=8.2.7 ; extra == "inspector"
30
32
  Requires-Dist: redis>=5.0 ; extra == "redis"
31
33
  Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
32
34
  Project-URL: Documentation, https://github.com/codingjoe/threadmill/
@@ -35,15 +37,14 @@ Project-URL: Homepage, https://github.com/codingjoe/threadmill
35
37
  Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
36
38
  Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
37
39
  Project-URL: Source, https://github.com/codingjoe/threadmill
40
+ Provides-Extra: inspector
38
41
  Provides-Extra: redis
39
42
 
40
- # Threadmill
41
-
42
43
  <p align="center">
43
44
  <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">
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">
47
48
  </picture>
48
49
  <br>
49
50
  <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
@@ -52,7 +53,9 @@ Provides-Extra: redis
52
53
  <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
53
54
  </p>
54
55
 
55
- **A queue agnostic worker for Django's task framework.**
56
+ # 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)
57
+
58
+ **Durable high-performance backend for Django's task framework.**
56
59
 
57
60
  ## Design Principles
58
61
 
@@ -60,13 +63,6 @@ Provides-Extra: redis
60
63
  - **Consistency** – We never lose data, even if someone unplugs the power or network.
61
64
  - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
62
65
 
63
- > [!WARNING]
64
- > Threadmill requires a development version of Django and is in a preview stage.
65
-
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)
69
-
70
66
  ## Sponsors
71
67
 
72
68
  [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
@@ -100,10 +96,16 @@ TASKS = {
100
96
  }
101
97
  ```
102
98
 
103
- Finally, you launch the worker pool:
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:
104
106
 
105
107
  ```console
106
- uv run manage.py threadmill
108
+ uv run manage.py threadmill worker
107
109
  ```
108
110
 
109
111
  ## Usage
@@ -118,7 +120,7 @@ Depending on your workload, you can tweak the number of processes and threads.
118
120
  Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
119
121
 
120
122
  ```console
121
- uv run manage.py threadmill --processes 4 --threads 2
123
+ uv run manage.py threadmill worker --processes 4 --threads 2
122
124
  ```
123
125
 
124
126
  #### Health
@@ -126,7 +128,7 @@ uv run manage.py threadmill --processes 4 --threads 2
126
128
  If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
127
129
 
128
130
  ```console
129
- uv run manage.py threadmill --max-tasks 1000 --max-tasks-jitter 100
131
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
130
132
  ```
131
133
 
132
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.
@@ -141,6 +143,18 @@ All workers will finish the tasks they acquired and acknowledge them.
141
143
  You can use `--exit-empty` to exit immediately after all tasks have been processed,
142
144
  which might be useful for draining a one-off queue.
143
145
 
146
+ ### Inspector
147
+
148
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
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
+
144
158
  ### Redis Backend Options
145
159
 
146
160
  The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
@@ -1,10 +1,8 @@
1
- # Threadmill
2
-
3
1
  <p align="center">
4
2
  <picture>
5
- <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
6
- <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
7
- <img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
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">
8
6
  </picture>
9
7
  <br>
10
8
  <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
@@ -13,7 +11,9 @@
13
11
  <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
14
12
  </p>
15
13
 
16
- **A queue agnostic worker for Django's task framework.**
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
17
 
18
18
  ## Design Principles
19
19
 
@@ -21,13 +21,6 @@
21
21
  - **Consistency** – We never lose data, even if someone unplugs the power or network.
22
22
  - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
23
23
 
24
- > [!WARNING]
25
- > Threadmill requires a development version of Django and is in a preview stage.
26
-
27
- [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/)
28
- [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill)
29
- [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
30
-
31
24
  ## Sponsors
32
25
 
33
26
  [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
@@ -61,10 +54,16 @@ TASKS = {
61
54
  }
62
55
  ```
63
56
 
64
- Finally, you launch the worker pool:
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:
65
64
 
66
65
  ```console
67
- uv run manage.py threadmill
66
+ uv run manage.py threadmill worker
68
67
  ```
69
68
 
70
69
  ## Usage
@@ -79,7 +78,7 @@ Depending on your workload, you can tweak the number of processes and threads.
79
78
  Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
80
79
 
81
80
  ```console
82
- uv run manage.py threadmill --processes 4 --threads 2
81
+ uv run manage.py threadmill worker --processes 4 --threads 2
83
82
  ```
84
83
 
85
84
  #### Health
@@ -87,7 +86,7 @@ uv run manage.py threadmill --processes 4 --threads 2
87
86
  If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
88
87
 
89
88
  ```console
90
- uv run manage.py threadmill --max-tasks 1000 --max-tasks-jitter 100
89
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
91
90
  ```
92
91
 
93
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.
@@ -102,6 +101,18 @@ All workers will finish the tasks they acquired and acknowledge them.
102
101
  You can use `--exit-empty` to exit immediately after all tasks have been processed,
103
102
  which might be useful for draining a one-off queue.
104
103
 
104
+ ### Inspector
105
+
106
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
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
+
105
116
  ### Redis Backend Options
106
117
 
107
118
  The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
@@ -30,13 +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.1a1"]
37
+ dependencies = ["django>=6.0"]
37
38
 
38
39
  [project.optional-dependencies]
39
40
  redis = ["redis>=5.0"]
41
+ inspector = [
42
+ "textual>=8.2.7",
43
+ ]
40
44
 
41
45
  [project.urls]
42
46
  # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
@@ -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.3.1'
22
+ __version_tuple__ = version_tuple = (0, 3, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gb135558ff'
24
+ __commit_id__ = commit_id = 'g32b680b8b'
@@ -7,12 +7,69 @@ import json
7
7
  import threading
8
8
  from abc import ABC
9
9
 
10
+ import django
10
11
  from django.core.serializers.json import DjangoJSONEncoder
11
12
  from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
12
13
  from django.tasks.backends.base import BaseTaskBackend
13
14
  from django.tasks.base import TaskError
14
15
  from django.utils.module_loading import import_string
15
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
+
16
73
 
17
74
  class Broker(threading.Thread):
18
75
  """Backend maintenance thread launched by the task executor."""
@@ -71,6 +128,7 @@ class TaskResultEncoder(DjangoJSONEncoder):
71
128
  class ThreadmillTaskBackend(BaseTaskBackend, ABC):
72
129
  """Interface for task queues to be processed by the executor."""
73
130
 
131
+ task_class = Task # can be removed in the future when Django 6.0 support is dropped
74
132
  supports_async_task = True
75
133
  supports_get_result = True
76
134
  broker_class: type[Broker] | None = None
@@ -138,8 +196,25 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
138
196
  self,
139
197
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
140
198
  *,
141
- status: TaskResultStatus | None = None,
199
+ status: TaskResultStatus,
142
200
  count: int = 1,
143
201
  ) -> collections.abc.Generator[TaskResult, None, None]:
144
- """Yield acknowledged results from a queue, optionally filtered by status."""
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
+ """
145
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
@@ -1,7 +1,9 @@
1
- -- Fail tasks whose processing lease has expired.
1
+ -- Fail tasks whose processing lease has expired. Reaped tasks are recorded
2
+ -- in the failed results history (the time series the inspector counts over),
3
+ -- then evicted once older than result_ttl.
2
4
  --
3
5
  -- KEYS[1] -- running set (ZSET)
4
- -- KEYS[2] -- results history set (ZSET)
6
+ -- KEYS[2] -- failed results history (ZSET, scored by finish time)
5
7
  -- ARGV[1] -- current time in milliseconds (for score comparison)
6
8
  -- ARGV[2] -- task key prefix (e.g. "threadmill:default:task:")
7
9
  -- ARGV[3] -- result key prefix (e.g. "threadmill:default:result:")
@@ -33,4 +35,6 @@ for _, task_id in ipairs(stale) do
33
35
  end
34
36
  end
35
37
  end
38
+ -- Evict failed results older than result_ttl to bound the history.
39
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], 0, tonumber(ARGV[1]) - tonumber(ARGV[5]) * 1000)
36
40
  return #stale